user-intent-discovery 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- user_intent_discovery/__init__.py +46 -0
- user_intent_discovery/api/__init__.py +0 -0
- user_intent_discovery/api/app.py +53 -0
- user_intent_discovery/api/cli.py +66 -0
- user_intent_discovery/api/routes.py +279 -0
- user_intent_discovery/api/schemas.py +138 -0
- user_intent_discovery/api/static/index.html +656 -0
- user_intent_discovery/caching.py +61 -0
- user_intent_discovery/client.py +88 -0
- user_intent_discovery/clustering.py +113 -0
- user_intent_discovery/embedding.py +116 -0
- user_intent_discovery/exceptions.py +26 -0
- user_intent_discovery/metrics.py +91 -0
- user_intent_discovery/pipeline.py +216 -0
- user_intent_discovery/reduction.py +95 -0
- user_intent_discovery/remote.py +384 -0
- user_intent_discovery/representatives.py +132 -0
- user_intent_discovery/results.py +109 -0
- user_intent_discovery/summarization.py +108 -0
- user_intent_discovery-1.1.0.dist-info/METADATA +275 -0
- user_intent_discovery-1.1.0.dist-info/RECORD +24 -0
- user_intent_discovery-1.1.0.dist-info/WHEEL +4 -0
- user_intent_discovery-1.1.0.dist-info/entry_points.txt +3 -0
- user_intent_discovery-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""user-intent-discovery: cluster chatbot questions and surface representative ones.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
TopicClusterer -- the orchestrator; call .fit(texts) to get results.
|
|
5
|
+
ClusteringResult -- the dataclass returned by fit().
|
|
6
|
+
|
|
7
|
+
Split-compute across machines: pass any of the ``Remote*`` stages into
|
|
8
|
+
``TopicClusterer`` (or use :func:`user_intent_discovery.remote.remote_stages` to build
|
|
9
|
+
all four at once) to run individual stages on a different server::
|
|
10
|
+
|
|
11
|
+
from user_intent_discovery import TopicClusterer
|
|
12
|
+
from user_intent_discovery.remote import remote_stages
|
|
13
|
+
|
|
14
|
+
stages = remote_stages("http://gpu-box:8000", cluster_dim=5, viz_dim=3)
|
|
15
|
+
result = TopicClusterer(**stages, min_cluster_size=3).fit(questions)
|
|
16
|
+
|
|
17
|
+
Everything else (custom embedders, summarizers, the API app factory) lives in
|
|
18
|
+
submodules and can be imported directly, e.g.::
|
|
19
|
+
|
|
20
|
+
from user_intent_discovery.summarization import OllamaSummarizer
|
|
21
|
+
from user_intent_discovery.api import create_app
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .client import TopicClustererClient
|
|
25
|
+
from .pipeline import TopicClusterer
|
|
26
|
+
from .remote import (
|
|
27
|
+
RemoteClusterer,
|
|
28
|
+
RemoteEmbedder,
|
|
29
|
+
RemoteReducer,
|
|
30
|
+
RemoteSummarizer,
|
|
31
|
+
remote_stages,
|
|
32
|
+
)
|
|
33
|
+
from .results import ClusteringResult
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"TopicClusterer",
|
|
37
|
+
"TopicClustererClient",
|
|
38
|
+
"ClusteringResult",
|
|
39
|
+
"RemoteEmbedder",
|
|
40
|
+
"RemoteReducer",
|
|
41
|
+
"RemoteClusterer",
|
|
42
|
+
"RemoteSummarizer",
|
|
43
|
+
"remote_stages",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
__version__ = "1.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from ..exceptions import OllamaConnectionError, ToolDiscoveryError
|
|
10
|
+
from ..summarization import Summarizer
|
|
11
|
+
from .routes import public_router, compute_router
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_app(
|
|
15
|
+
summarizer: Summarizer | None = None,
|
|
16
|
+
api_key: str | None = None,
|
|
17
|
+
cors_origins: list[str] | None = None,
|
|
18
|
+
) -> FastAPI:
|
|
19
|
+
"""Build the FastAPI app.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
summarizer: If provided, the /summarize endpoint and UI tab are enabled.
|
|
23
|
+
If None, /summarize returns 503 and the UI greys the tab out.
|
|
24
|
+
api_key: If set, every compute endpoint requires ``X-API-Key`` to match.
|
|
25
|
+
``/`` (UI) and ``/health`` remain public.
|
|
26
|
+
cors_origins: Allowed origins for cross-origin requests. ``None`` (the
|
|
27
|
+
default) allows any origin — appropriate for local dev; set an
|
|
28
|
+
explicit list in production.
|
|
29
|
+
"""
|
|
30
|
+
app = FastAPI(title="user-intent-discovery", version="1.1.0")
|
|
31
|
+
app.state.summarizer = summarizer
|
|
32
|
+
app.state.api_key = api_key
|
|
33
|
+
|
|
34
|
+
app.add_middleware(
|
|
35
|
+
CORSMiddleware,
|
|
36
|
+
allow_origins=cors_origins if cors_origins is not None else ["*"],
|
|
37
|
+
allow_credentials=True,
|
|
38
|
+
allow_methods=["*"],
|
|
39
|
+
allow_headers=["*"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
app.include_router(public_router)
|
|
43
|
+
app.include_router(compute_router)
|
|
44
|
+
|
|
45
|
+
@app.exception_handler(OllamaConnectionError)
|
|
46
|
+
def _ollama_down(request: Request, exc: OllamaConnectionError) -> JSONResponse:
|
|
47
|
+
return JSONResponse(status_code=503, content={"detail": str(exc)})
|
|
48
|
+
|
|
49
|
+
@app.exception_handler(ToolDiscoveryError)
|
|
50
|
+
def _tool_error(request: Request, exc: ToolDiscoveryError) -> JSONResponse:
|
|
51
|
+
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
|
52
|
+
|
|
53
|
+
return app
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Console entry point: ``user-intent-discovery serve``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from ..summarization import OllamaSummarizer
|
|
11
|
+
from .app import create_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="user-intent-discovery")
|
|
16
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
serve = sub.add_parser("serve", help="Run the API + UI server.")
|
|
19
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
20
|
+
serve.add_argument("--port", type=int, default=8000)
|
|
21
|
+
serve.add_argument("--ollama-url", default="http://localhost:11434")
|
|
22
|
+
serve.add_argument("--ollama-model", default="llama3.2")
|
|
23
|
+
serve.add_argument(
|
|
24
|
+
"--no-summarizer",
|
|
25
|
+
action="store_true",
|
|
26
|
+
help="Disable summarization (UI summarize tab greyed out).",
|
|
27
|
+
)
|
|
28
|
+
serve.add_argument(
|
|
29
|
+
"--api-key",
|
|
30
|
+
default=None,
|
|
31
|
+
help=(
|
|
32
|
+
"Require this key in the X-API-Key header on compute endpoints. "
|
|
33
|
+
"Falls back to $USER_INTENT_DISCOVERY_API_KEY if unset. "
|
|
34
|
+
"/health and / remain public."
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
serve.add_argument(
|
|
38
|
+
"--cors-origin",
|
|
39
|
+
action="append",
|
|
40
|
+
default=None,
|
|
41
|
+
dest="cors_origins",
|
|
42
|
+
help=(
|
|
43
|
+
"Allowed CORS origin (repeatable). Omit to allow any origin "
|
|
44
|
+
"(fine for local dev; set explicit origins in production)."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
|
|
50
|
+
api_key = args.api_key or os.environ.get("USER_INTENT_DISCOVERY_API_KEY")
|
|
51
|
+
|
|
52
|
+
summarizer = (
|
|
53
|
+
None
|
|
54
|
+
if args.no_summarizer
|
|
55
|
+
else OllamaSummarizer(model=args.ollama_model, base_url=args.ollama_url)
|
|
56
|
+
)
|
|
57
|
+
app = create_app(
|
|
58
|
+
summarizer=summarizer,
|
|
59
|
+
api_key=api_key,
|
|
60
|
+
cors_origins=args.cors_origins,
|
|
61
|
+
)
|
|
62
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""HTTP endpoints. Each stage is exposed separately, plus a one-shot /pipeline.
|
|
2
|
+
|
|
3
|
+
Split into two routers: ``public_router`` (UI + /health, always open) and
|
|
4
|
+
``compute_router`` (every stage endpoint, guarded by an optional X-API-Key
|
|
5
|
+
check driven by ``app.state.api_key``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import requests
|
|
17
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
18
|
+
from fastapi.responses import FileResponse
|
|
19
|
+
|
|
20
|
+
from ..clustering import HdbscanClusterer
|
|
21
|
+
from ..embedding import SentenceTransformerEmbedder
|
|
22
|
+
from ..metrics import compute_metrics
|
|
23
|
+
from ..pipeline import TopicClusterer
|
|
24
|
+
from ..reduction import UmapReducer
|
|
25
|
+
from ..representatives import reassign_noise as reassign_noise_fn
|
|
26
|
+
from ..summarization import OllamaSummarizer, Summarizer
|
|
27
|
+
from .schemas import (
|
|
28
|
+
ClusterRequest,
|
|
29
|
+
ClusterResponse,
|
|
30
|
+
EmbedRequest,
|
|
31
|
+
EmbedResponse,
|
|
32
|
+
HealthResponse,
|
|
33
|
+
IngestRequest,
|
|
34
|
+
IngestResponse,
|
|
35
|
+
MAX_INGEST_BODY_BYTES,
|
|
36
|
+
MAX_TEXTS,
|
|
37
|
+
PipelineRequest,
|
|
38
|
+
ReduceRequest,
|
|
39
|
+
ReduceResponse,
|
|
40
|
+
SummarizeRequest,
|
|
41
|
+
SummarizeResponse,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_STATIC = Path(__file__).parent / "static"
|
|
45
|
+
# Fixed, server-controlled cache location — clients may only opt in/out,
|
|
46
|
+
# never choose the path. Anchored to the project root, not the request.
|
|
47
|
+
_CACHE_DIR = Path(__file__).resolve().parents[3] / "embedding_cache"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_summarizer(request: Request) -> Summarizer | None:
|
|
51
|
+
"""Pull the summarizer the app was created with off app.state."""
|
|
52
|
+
return getattr(request.app.state, "summarizer", None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _require_api_key(
|
|
56
|
+
request: Request,
|
|
57
|
+
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Reject the request when an api_key is configured and the header is wrong.
|
|
60
|
+
|
|
61
|
+
Applied to every compute route; no-op when no api_key is configured, so the
|
|
62
|
+
default open server keeps working without changes.
|
|
63
|
+
"""
|
|
64
|
+
expected = getattr(request.app.state, "api_key", None)
|
|
65
|
+
if expected and x_api_key != expected:
|
|
66
|
+
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
public_router = APIRouter()
|
|
70
|
+
compute_router = APIRouter(dependencies=[Depends(_require_api_key)])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@public_router.get("/health", response_model=HealthResponse)
|
|
74
|
+
def health(request: Request) -> HealthResponse:
|
|
75
|
+
return HealthResponse(
|
|
76
|
+
summarizer_available=_get_summarizer(request) is not None,
|
|
77
|
+
auth_required=getattr(request.app.state, "api_key", None) is not None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@public_router.get("/")
|
|
82
|
+
def index() -> FileResponse:
|
|
83
|
+
"""Serve the browser UI."""
|
|
84
|
+
return FileResponse(_STATIC / "index.html")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@compute_router.post("/embed", response_model=EmbedResponse)
|
|
88
|
+
def embed(req: EmbedRequest) -> EmbedResponse:
|
|
89
|
+
embedder = SentenceTransformerEmbedder(
|
|
90
|
+
model_name=req.model or "sentence-transformers/all-MiniLM-L6-v2",
|
|
91
|
+
normalize=req.normalize,
|
|
92
|
+
)
|
|
93
|
+
vectors = embedder.embed(req.texts)
|
|
94
|
+
return EmbedResponse(vectors=vectors.tolist(), dimension=embedder.dimension)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@compute_router.post("/reduce", response_model=ReduceResponse)
|
|
98
|
+
def reduce(req: ReduceRequest) -> ReduceResponse:
|
|
99
|
+
coords = UmapReducer(
|
|
100
|
+
n_components=req.target_dim,
|
|
101
|
+
n_neighbors=req.n_neighbors,
|
|
102
|
+
min_dist=req.min_dist,
|
|
103
|
+
metric=req.metric,
|
|
104
|
+
random_state=req.random_state,
|
|
105
|
+
).fit_transform(np.asarray(req.vectors, dtype=np.float32))
|
|
106
|
+
return ReduceResponse(coords=coords.tolist())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@compute_router.post("/cluster", response_model=ClusterResponse)
|
|
110
|
+
def cluster(req: ClusterRequest) -> ClusterResponse:
|
|
111
|
+
coords = np.asarray(req.coords, dtype=np.float32)
|
|
112
|
+
labels, persistence = HdbscanClusterer(
|
|
113
|
+
min_cluster_size=req.min_cluster_size,
|
|
114
|
+
min_samples=req.min_samples,
|
|
115
|
+
cluster_selection_method=req.cluster_selection_method,
|
|
116
|
+
cluster_selection_epsilon=req.cluster_selection_epsilon,
|
|
117
|
+
).fit(coords)
|
|
118
|
+
|
|
119
|
+
# Metrics computed on ORIGINAL labels (noise counted honestly).
|
|
120
|
+
metrics = compute_metrics(coords, labels, persistence)
|
|
121
|
+
|
|
122
|
+
out_labels = labels
|
|
123
|
+
if req.reassign_noise:
|
|
124
|
+
out_labels = reassign_noise_fn(coords, labels)
|
|
125
|
+
|
|
126
|
+
return ClusterResponse(
|
|
127
|
+
labels=[int(x) for x in out_labels],
|
|
128
|
+
persistence=persistence,
|
|
129
|
+
metrics=asdict(metrics),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@compute_router.post("/pipeline")
|
|
134
|
+
def pipeline(req: PipelineRequest) -> dict:
|
|
135
|
+
"""One-shot: what the browser UI calls on submit. Never summarizes here."""
|
|
136
|
+
cache_dir = str(_CACHE_DIR) if req.cache_embeddings else None
|
|
137
|
+
clusterer = TopicClusterer(
|
|
138
|
+
embedding_model=req.embedding_model,
|
|
139
|
+
normalize_embeddings=req.normalize_embeddings,
|
|
140
|
+
embedding_cache_dir=cache_dir,
|
|
141
|
+
cluster_dim=req.cluster_dim,
|
|
142
|
+
umap_n_neighbors=req.umap_n_neighbors,
|
|
143
|
+
umap_min_dist=req.umap_min_dist,
|
|
144
|
+
umap_metric=req.umap_metric,
|
|
145
|
+
viz_dim=req.viz_dim,
|
|
146
|
+
min_cluster_size=req.min_cluster_size,
|
|
147
|
+
min_samples=req.min_samples,
|
|
148
|
+
cluster_selection_method=req.cluster_selection_method,
|
|
149
|
+
cluster_selection_epsilon=req.cluster_selection_epsilon,
|
|
150
|
+
top_n_per_cluster=req.top_n_per_cluster,
|
|
151
|
+
top_k_clusters=req.top_k_clusters,
|
|
152
|
+
representative_space=req.representative_space,
|
|
153
|
+
reassign_noise=req.reassign_noise,
|
|
154
|
+
summarizer=None,
|
|
155
|
+
random_state=req.random_state,
|
|
156
|
+
)
|
|
157
|
+
return clusterer.fit(req.texts).to_dict()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@compute_router.post("/summarize", response_model=SummarizeResponse)
|
|
161
|
+
def summarize(req: SummarizeRequest, request: Request) -> SummarizeResponse:
|
|
162
|
+
default = _get_summarizer(request)
|
|
163
|
+
if default is None and req.model is None:
|
|
164
|
+
raise HTTPException(status_code=503, detail="No summarizer configured.")
|
|
165
|
+
|
|
166
|
+
# Per-request override: build a summarizer for the named model, reusing the
|
|
167
|
+
# server's Ollama URL if the default is an OllamaSummarizer.
|
|
168
|
+
if req.model:
|
|
169
|
+
base_url = getattr(default, "base_url", "http://localhost:11434")
|
|
170
|
+
summarizer = OllamaSummarizer(model=req.model, base_url=base_url)
|
|
171
|
+
else:
|
|
172
|
+
summarizer = default
|
|
173
|
+
|
|
174
|
+
summaries = {
|
|
175
|
+
c.cluster_id: summarizer.summarize(c.cluster_id, c.representatives)
|
|
176
|
+
for c in req.clusters
|
|
177
|
+
}
|
|
178
|
+
return SummarizeResponse(summaries=summaries)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------- /ingest ----------
|
|
182
|
+
# UI + backend-to-backend helper: fetch a URL server-side and hand back a
|
|
183
|
+
# flat list of strings. Server-side because browsers block cross-origin
|
|
184
|
+
# fetches (CORS) and cannot inject Authorization headers from the frontend.
|
|
185
|
+
|
|
186
|
+
_TEXT_KEYS_IN_OBJECT = ("text", "question", "prompt", "query", "content")
|
|
187
|
+
_LIST_KEYS_IN_ROOT = ("questions", "texts", "prompts", "queries", "items", "data")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _extract_texts_from_json(data: object) -> list[str]:
|
|
191
|
+
"""Turn a decoded JSON body into a flat list of strings.
|
|
192
|
+
|
|
193
|
+
Accepts three shapes:
|
|
194
|
+
- ``["q1", "q2", ...]``
|
|
195
|
+
- ``[{"text": "q1"}, {"question": "q2"}, ...]`` (first hit wins)
|
|
196
|
+
- ``{"questions": [...]}`` / ``{"texts": [...]}`` / ``{"items": [...]}`` / ``{"data": [...]}``
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(data, list):
|
|
199
|
+
if all(isinstance(x, str) for x in data):
|
|
200
|
+
return [x.strip() for x in data if x and x.strip()]
|
|
201
|
+
out: list[str] = []
|
|
202
|
+
for item in data:
|
|
203
|
+
if isinstance(item, dict):
|
|
204
|
+
for k in _TEXT_KEYS_IN_OBJECT:
|
|
205
|
+
v = item.get(k)
|
|
206
|
+
if isinstance(v, str) and v.strip():
|
|
207
|
+
out.append(v.strip())
|
|
208
|
+
break
|
|
209
|
+
return out
|
|
210
|
+
if isinstance(data, dict):
|
|
211
|
+
for k in _LIST_KEYS_IN_ROOT:
|
|
212
|
+
if k in data:
|
|
213
|
+
return _extract_texts_from_json(data[k])
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _extract_texts_from_body(resp: requests.Response) -> list[str]:
|
|
218
|
+
"""Try JSON first, fall back to comma/newline-splitting on plain text."""
|
|
219
|
+
try:
|
|
220
|
+
return _extract_texts_from_json(resp.json())
|
|
221
|
+
except ValueError:
|
|
222
|
+
pass
|
|
223
|
+
# Plain text: split on newlines and commas, strip and drop empties.
|
|
224
|
+
parts = re.split(r"[\n,]", resp.text)
|
|
225
|
+
return [p.strip() for p in parts if p.strip()]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@compute_router.post("/ingest", response_model=IngestResponse)
|
|
229
|
+
def ingest(req: IngestRequest) -> IngestResponse:
|
|
230
|
+
"""Fetch texts from an external URL server-side (bypasses browser CORS)."""
|
|
231
|
+
parsed = urlparse(req.url)
|
|
232
|
+
if parsed.scheme not in ("http", "https"):
|
|
233
|
+
raise HTTPException(
|
|
234
|
+
status_code=400, detail="Only http:// and https:// URLs are allowed."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
# stream=True so we can enforce MAX_INGEST_BODY_BYTES before loading it all
|
|
239
|
+
with requests.get(
|
|
240
|
+
req.url,
|
|
241
|
+
headers=req.headers or {},
|
|
242
|
+
timeout=req.timeout,
|
|
243
|
+
stream=True,
|
|
244
|
+
allow_redirects=True,
|
|
245
|
+
) as resp:
|
|
246
|
+
resp.raise_for_status()
|
|
247
|
+
# Reject bodies larger than the cap (checked incrementally so a
|
|
248
|
+
# malicious server can't stream forever).
|
|
249
|
+
content = bytearray()
|
|
250
|
+
for chunk in resp.iter_content(chunk_size=64 * 1024):
|
|
251
|
+
if not chunk:
|
|
252
|
+
continue
|
|
253
|
+
content.extend(chunk)
|
|
254
|
+
if len(content) > MAX_INGEST_BODY_BYTES:
|
|
255
|
+
raise HTTPException(
|
|
256
|
+
status_code=413,
|
|
257
|
+
detail=(
|
|
258
|
+
f"Upstream response exceeded "
|
|
259
|
+
f"{MAX_INGEST_BODY_BYTES // 1_000_000} MB cap."
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
resp._content = bytes(content) # so .json() / .text see the buffered body
|
|
263
|
+
except requests.exceptions.RequestException as exc:
|
|
264
|
+
raise HTTPException(status_code=502, detail=f"Upstream fetch failed: {exc}")
|
|
265
|
+
|
|
266
|
+
texts = _extract_texts_from_body(resp)
|
|
267
|
+
if not texts:
|
|
268
|
+
raise HTTPException(
|
|
269
|
+
status_code=422,
|
|
270
|
+
detail=(
|
|
271
|
+
"Could not extract any texts from the response. Expected a JSON list "
|
|
272
|
+
"of strings, a list of objects with a text/question/prompt field, an "
|
|
273
|
+
"object containing a 'questions'/'texts'/'items' list, or a "
|
|
274
|
+
"newline/comma-separated plain-text body."
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
if len(texts) > MAX_TEXTS:
|
|
278
|
+
texts = texts[:MAX_TEXTS] # cap silently; the UI will show the count.
|
|
279
|
+
return IngestResponse(texts=texts, count=len(texts))
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Pydantic request/response models.
|
|
2
|
+
|
|
3
|
+
Input caps are applied at the schema layer so garbage requests are rejected
|
|
4
|
+
at the boundary (413/422) rather than exploding memory later. Bump the
|
|
5
|
+
constants below if a real workload legitimately needs more headroom.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, StringConstraints
|
|
13
|
+
|
|
14
|
+
# ---------- caps ----------
|
|
15
|
+
MAX_TEXTS = 20_000 # per-request text count
|
|
16
|
+
MAX_TEXT_LENGTH = 5_000 # per-text character count
|
|
17
|
+
MAX_VECTORS = 50_000 # per-request vector count for /reduce, /cluster
|
|
18
|
+
MAX_VECTOR_DIM = 4_096 # sanity cap on embedding dimensionality
|
|
19
|
+
MAX_CLUSTERS_PER_SUMMARIZE = 500
|
|
20
|
+
MAX_REPS_PER_CLUSTER = 100
|
|
21
|
+
MAX_URL_LENGTH = 2_048 # for /ingest — usual browser limit
|
|
22
|
+
MAX_INGEST_BODY_BYTES = 10 * 1024 * 1024 # 10 MB cap on the fetched body
|
|
23
|
+
|
|
24
|
+
# A single non-empty text, whitespace-stripped, length-bounded.
|
|
25
|
+
Text = Annotated[
|
|
26
|
+
str,
|
|
27
|
+
StringConstraints(
|
|
28
|
+
min_length=1, max_length=MAX_TEXT_LENGTH, strip_whitespace=True
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------- /embed ----------
|
|
34
|
+
class EmbedRequest(BaseModel):
|
|
35
|
+
texts: list[Text] = Field(min_length=1, max_length=MAX_TEXTS)
|
|
36
|
+
model: str | None = None
|
|
37
|
+
normalize: bool = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EmbedResponse(BaseModel):
|
|
41
|
+
vectors: list[list[float]]
|
|
42
|
+
dimension: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------- /reduce ----------
|
|
46
|
+
class ReduceRequest(BaseModel):
|
|
47
|
+
vectors: list[list[float]] = Field(min_length=1, max_length=MAX_VECTORS)
|
|
48
|
+
target_dim: int = Field(default=5, ge=1, le=100)
|
|
49
|
+
n_neighbors: int = Field(default=15, ge=2, le=1_000)
|
|
50
|
+
min_dist: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
51
|
+
metric: str = "cosine"
|
|
52
|
+
random_state: int | None = 42
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReduceResponse(BaseModel):
|
|
56
|
+
coords: list[list[float]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------- /cluster ----------
|
|
60
|
+
class ClusterRequest(BaseModel):
|
|
61
|
+
coords: list[list[float]] = Field(min_length=2, max_length=MAX_VECTORS)
|
|
62
|
+
min_cluster_size: int = Field(default=5, ge=2)
|
|
63
|
+
min_samples: int | None = None
|
|
64
|
+
cluster_selection_method: str = "eom"
|
|
65
|
+
cluster_selection_epsilon: float = Field(default=0.0, ge=0.0)
|
|
66
|
+
reassign_noise: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ClusterResponse(BaseModel):
|
|
70
|
+
labels: list[int]
|
|
71
|
+
persistence: dict[int, float]
|
|
72
|
+
metrics: dict
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------- /pipeline ----------
|
|
76
|
+
class PipelineRequest(BaseModel):
|
|
77
|
+
texts: list[Text] = Field(min_length=1, max_length=MAX_TEXTS)
|
|
78
|
+
# Embedding
|
|
79
|
+
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
|
|
80
|
+
normalize_embeddings: bool = True
|
|
81
|
+
cache_embeddings: bool = False
|
|
82
|
+
# Reduction
|
|
83
|
+
cluster_dim: int = Field(default=5, ge=1, le=100)
|
|
84
|
+
umap_n_neighbors: int = Field(default=15, ge=2, le=1_000)
|
|
85
|
+
umap_min_dist: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
86
|
+
umap_metric: str = "cosine"
|
|
87
|
+
viz_dim: int = Field(default=3, ge=0, le=100)
|
|
88
|
+
# Clustering
|
|
89
|
+
min_cluster_size: int = Field(default=5, ge=2)
|
|
90
|
+
min_samples: int | None = None
|
|
91
|
+
cluster_selection_method: str = "eom"
|
|
92
|
+
cluster_selection_epsilon: float = Field(default=0.0, ge=0.0)
|
|
93
|
+
# Representatives
|
|
94
|
+
top_n_per_cluster: int = Field(default=5, ge=1, le=1_000)
|
|
95
|
+
top_k_clusters: int | None = None
|
|
96
|
+
representative_space: str = "embedding"
|
|
97
|
+
# Noise
|
|
98
|
+
reassign_noise: bool = False
|
|
99
|
+
# Reproducibility
|
|
100
|
+
random_state: int | None = 42
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------- /summarize ----------
|
|
104
|
+
class ClusterToSummarize(BaseModel):
|
|
105
|
+
cluster_id: int
|
|
106
|
+
representatives: list[Text] = Field(
|
|
107
|
+
min_length=1, max_length=MAX_REPS_PER_CLUSTER
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SummarizeRequest(BaseModel):
|
|
112
|
+
clusters: list[ClusterToSummarize] = Field(
|
|
113
|
+
min_length=1, max_length=MAX_CLUSTERS_PER_SUMMARIZE
|
|
114
|
+
)
|
|
115
|
+
model: str | None = None # None = use the server's default summarizer
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SummarizeResponse(BaseModel):
|
|
119
|
+
summaries: dict[int, str]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------- /ingest ----------
|
|
123
|
+
class IngestRequest(BaseModel):
|
|
124
|
+
url: str = Field(min_length=1, max_length=MAX_URL_LENGTH)
|
|
125
|
+
headers: dict[str, str] | None = None # e.g. {"Authorization": "Bearer …"}
|
|
126
|
+
timeout: float = Field(default=30.0, ge=1.0, le=120.0)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class IngestResponse(BaseModel):
|
|
130
|
+
texts: list[str]
|
|
131
|
+
count: int
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------- /health ----------
|
|
135
|
+
class HealthResponse(BaseModel):
|
|
136
|
+
status: str = "ok"
|
|
137
|
+
summarizer_available: bool
|
|
138
|
+
auth_required: bool = False
|