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.
@@ -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