user-intent-discovery 1.1.0__tar.gz
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-1.1.0/LICENSE +21 -0
- user_intent_discovery-1.1.0/PKG-INFO +275 -0
- user_intent_discovery-1.1.0/README.md +237 -0
- user_intent_discovery-1.1.0/pyproject.toml +96 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/__init__.py +46 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/api/__init__.py +0 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/api/app.py +53 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/api/cli.py +66 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/api/routes.py +279 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/api/schemas.py +138 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/api/static/index.html +656 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/caching.py +61 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/client.py +88 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/clustering.py +113 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/embedding.py +116 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/exceptions.py +26 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/metrics.py +91 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/pipeline.py +216 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/reduction.py +95 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/remote.py +384 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/representatives.py +132 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/results.py +109 -0
- user_intent_discovery-1.1.0/src/user_intent_discovery/summarization.py +108 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nithin Vikas AV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: user-intent-discovery
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Cluster chatbot questions by semantic similarity and surface the most representative ones, with optional local-LLM summarization. Each pipeline stage (embed, reduce, cluster, summarize) can be run locally or against a remote server.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: clustering,embeddings,umap,hdbscan,chatbot,topic-modeling,sentence-transformers,ollama
|
|
8
|
+
Author: Nithin Vikas AV
|
|
9
|
+
Author-email: nithinvikas.av@foxsense.io
|
|
10
|
+
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Provides-Extra: ui
|
|
25
|
+
Requires-Dist: fastapi (>=0.110) ; extra == "ui"
|
|
26
|
+
Requires-Dist: hdbscan (>=0.8.33)
|
|
27
|
+
Requires-Dist: numpy (>=1.24)
|
|
28
|
+
Requires-Dist: requests (>=2.31)
|
|
29
|
+
Requires-Dist: scikit-learn (>=1.3)
|
|
30
|
+
Requires-Dist: sentence-transformers (>=2.5)
|
|
31
|
+
Requires-Dist: umap-learn (>=0.5.5)
|
|
32
|
+
Requires-Dist: uvicorn[standard] (>=0.29) ; extra == "ui"
|
|
33
|
+
Project-URL: Documentation, https://bitbucket.org/foxsense/user-intent-discovery
|
|
34
|
+
Project-URL: Homepage, https://bitbucket.org/foxsense/user-intent-discovery
|
|
35
|
+
Project-URL: Repository, https://bitbucket.org/foxsense/user-intent-discovery
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# user-intent-discovery
|
|
39
|
+
|
|
40
|
+
Cluster short texts (chatbot questions, feedback items, search queries — anything sentence-sized) by semantic similarity, surface the most representative items per cluster, and optionally synthesize each cluster into a single "general question" with a local LLM.
|
|
41
|
+
|
|
42
|
+
Ships as an importable **Python library**, an optional **FastAPI service with a browser UI**, and a set of **remote stage clients** — so any stage of the pipeline (embed, reduce, cluster, summarize) can run locally or against a different machine. Heavy stages on the GPU box, light ones on your laptop.
|
|
43
|
+
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
embed -> reduce (UMAP) -> cluster (HDBSCAN) -> representatives -> metrics -> summarize (optional)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
1. **Embed** each text into a vector with `sentence-transformers`.
|
|
51
|
+
2. **Reduce** to a low-dimensional space with UMAP (density clustering works poorly in high dimensions).
|
|
52
|
+
3. **Cluster** with HDBSCAN, which finds clusters of varying density, labels outliers as noise, and does not require choosing the number of clusters up front.
|
|
53
|
+
4. **Extract representatives**: the N items closest to each cluster's center.
|
|
54
|
+
5. **Score** with silhouette, HDBSCAN persistence, DBCV, and noise ratio.
|
|
55
|
+
6. **Summarize** (optional): one general question per cluster via a local Ollama model.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install user-intent-discovery # library + remote clients
|
|
61
|
+
pip install "user-intent-discovery[ui]" # + FastAPI service and browser UI
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Python 3.10+.
|
|
65
|
+
|
|
66
|
+
## Library usage
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from user_intent_discovery import TopicClusterer
|
|
70
|
+
|
|
71
|
+
questions = [
|
|
72
|
+
"How do I reset my password?",
|
|
73
|
+
"I forgot my password, help",
|
|
74
|
+
"What are your business hours?",
|
|
75
|
+
"When are you open?",
|
|
76
|
+
# ...
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
clusterer = TopicClusterer(min_cluster_size=3)
|
|
80
|
+
result = clusterer.fit(questions)
|
|
81
|
+
|
|
82
|
+
print(f"Found {result.metrics.n_clusters} clusters "
|
|
83
|
+
f"({result.metrics.n_noise} noise points)")
|
|
84
|
+
|
|
85
|
+
for cluster_id in result.top_k_clusters:
|
|
86
|
+
info = result.clusters[cluster_id]
|
|
87
|
+
print(f"\nCluster {cluster_id} ({info.size} items):")
|
|
88
|
+
for rep in info.representatives:
|
|
89
|
+
print(f" - {rep.text}")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### With summarization
|
|
93
|
+
|
|
94
|
+
Summarization needs a running [Ollama](https://ollama.com) server:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from user_intent_discovery import TopicClusterer
|
|
98
|
+
from user_intent_discovery.summarization import OllamaSummarizer
|
|
99
|
+
|
|
100
|
+
clusterer = TopicClusterer(
|
|
101
|
+
min_cluster_size=3,
|
|
102
|
+
summarizer=OllamaSummarizer(model="llama3.2"),
|
|
103
|
+
)
|
|
104
|
+
result = clusterer.fit(questions)
|
|
105
|
+
|
|
106
|
+
for cluster_id, question in (result.summaries or {}).items():
|
|
107
|
+
print(f"Cluster {cluster_id}: {question}")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Split-compute across machines
|
|
111
|
+
|
|
112
|
+
The pipeline has four stages and each is behind a protocol (`Embedder`, `Reducer`, `Clusterer`, `Summarizer`). Every protocol has a local implementation and a remote one — `RemoteEmbedder`, `RemoteReducer`, `RemoteClusterer`, `RemoteSummarizer` — that talks to a running `user-intent-discovery` server. Any stage the developer doesn't inject falls back to the local default, so mix-and-match is free.
|
|
113
|
+
|
|
114
|
+
**All-remote** (laptop only orchestrates; every heavy stage runs elsewhere):
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from user_intent_discovery import TopicClusterer, remote_stages
|
|
118
|
+
|
|
119
|
+
stages = remote_stages(
|
|
120
|
+
"http://gpu-box:8000",
|
|
121
|
+
cluster_dim=5, viz_dim=3,
|
|
122
|
+
min_cluster_size=3,
|
|
123
|
+
api_key="s3cret", # optional; matched against the server's --api-key
|
|
124
|
+
)
|
|
125
|
+
result = TopicClusterer(**stages).fit(questions)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Selective remote** (embed + summarize remote, UMAP + HDBSCAN local):
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from user_intent_discovery import TopicClusterer, RemoteEmbedder, RemoteSummarizer
|
|
132
|
+
|
|
133
|
+
clusterer = TopicClusterer(
|
|
134
|
+
embedder = RemoteEmbedder("http://gpu-box:8000"),
|
|
135
|
+
summarizer = RemoteSummarizer("http://gpu-box:8000", model="llama3.2"),
|
|
136
|
+
min_cluster_size=3, # UMAP + HDBSCAN kwargs still apply to local defaults
|
|
137
|
+
)
|
|
138
|
+
result = clusterer.fit(questions)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The wire format is JSON (nested float lists). It is fine for tens of thousands of items; a binary format is on the v2 roadmap.
|
|
142
|
+
|
|
143
|
+
### One-shot remote (no local pipeline)
|
|
144
|
+
|
|
145
|
+
If you only want to POST texts to a server and get the full result back — no injection, no mix-and-match — use `TopicClustererClient`:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from user_intent_discovery import TopicClustererClient
|
|
149
|
+
|
|
150
|
+
client = TopicClustererClient("http://gpu-box:8000")
|
|
151
|
+
result = client.fit(questions, min_cluster_size=3) # returns a dict
|
|
152
|
+
print(result["metrics"]["n_clusters"])
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
It only depends on `requests`, so it's the right choice for machines that can't install the ML stack.
|
|
156
|
+
|
|
157
|
+
## Web service and UI
|
|
158
|
+
|
|
159
|
+
Installed with the `[ui]` extra:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
user-intent-discovery serve # http://127.0.0.1:8000
|
|
163
|
+
user-intent-discovery serve --port 9000 --ollama-model llama3.2
|
|
164
|
+
user-intent-discovery serve --no-summarizer # UI summarize tab greyed out
|
|
165
|
+
|
|
166
|
+
# production-ish
|
|
167
|
+
user-intent-discovery serve \
|
|
168
|
+
--host 0.0.0.0 --port 8000 \
|
|
169
|
+
--api-key "$(openssl rand -hex 16)" \
|
|
170
|
+
--cors-origin https://app.example.com \
|
|
171
|
+
--cors-origin https://staging.example.com
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Open the URL in a browser to paste questions, tune parameters, view the 3D cluster map, and summarize clusters.
|
|
175
|
+
|
|
176
|
+
### API endpoints
|
|
177
|
+
|
|
178
|
+
| Method | Path | Purpose |
|
|
179
|
+
|--------|--------------|----------------------------------------------|
|
|
180
|
+
| `POST` | `/ingest` | Fetch texts from an external URL server-side |
|
|
181
|
+
| `POST` | `/embed` | Texts to embedding vectors |
|
|
182
|
+
| `POST` | `/reduce` | Vectors to reduced coordinates |
|
|
183
|
+
| `POST` | `/cluster` | Coordinates to labels + metrics |
|
|
184
|
+
| `POST` | `/pipeline` | One-shot: texts to full result |
|
|
185
|
+
| `POST` | `/summarize` | Cluster representatives to a question |
|
|
186
|
+
| `GET` | `/health` | Status + `summarizer_available` + `auth_required` |
|
|
187
|
+
| `GET` | `/` | The browser UI |
|
|
188
|
+
|
|
189
|
+
The staged endpoints let other apps plug in at a single stage; `/pipeline` is the convenience path the UI uses.
|
|
190
|
+
|
|
191
|
+
### `/ingest` — fetch questions from your backend
|
|
192
|
+
|
|
193
|
+
The browser can't fetch cross-origin URLs (CORS) and can't inject auth headers safely, so this endpoint runs the fetch server-side and hands the extracted texts back. The UI's **Fetch from URL** button uses this route.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
curl -sX POST http://127.0.0.1:8000/ingest \
|
|
197
|
+
-H 'Content-Type: application/json' \
|
|
198
|
+
-d '{
|
|
199
|
+
"url": "https://chatbot.example.com/api/unanswered",
|
|
200
|
+
"headers": {"Authorization": "Bearer '"$CHATBOT_TOKEN"'"}
|
|
201
|
+
}'
|
|
202
|
+
# => {"texts": ["q1","q2",...], "count": 42}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Accepted upstream response shapes (auto-detected):
|
|
206
|
+
|
|
207
|
+
- JSON list of strings: `["q1", "q2"]`
|
|
208
|
+
- JSON list of objects, keys tried in order: `text`, `question`, `prompt`, `query`, `content`
|
|
209
|
+
- JSON object with a list under `questions` / `texts` / `prompts` / `queries` / `items` / `data`
|
|
210
|
+
- Plain text — split on newlines and commas
|
|
211
|
+
|
|
212
|
+
Errors: `400` for a non-http(s) URL, `413` if the upstream body exceeds 10 MB, `502` on any upstream network failure, `422` if the body has no extractable texts.
|
|
213
|
+
|
|
214
|
+
### Auth and CORS
|
|
215
|
+
|
|
216
|
+
- **`--api-key VALUE`** (or `USER_INTENT_DISCOVERY_API_KEY` env var). When set, every compute endpoint requires `X-API-Key: VALUE`. `/health` and `/` stay open, and `/health` reports `auth_required: true` so clients can adapt.
|
|
217
|
+
- **`--cors-origin URL`** (repeatable). Omit for permissive `*` (dev only); set explicit origins in production.
|
|
218
|
+
|
|
219
|
+
### Input caps
|
|
220
|
+
|
|
221
|
+
The server rejects over-large requests at the schema layer with HTTP 422, before any pipeline work:
|
|
222
|
+
|
|
223
|
+
| Cap | Default | Applies to |
|
|
224
|
+
|------------------------------|-----------|-------------------------------------|
|
|
225
|
+
| `MAX_TEXTS` | 20,000 | `/embed`, `/pipeline` text lists |
|
|
226
|
+
| `MAX_TEXT_LENGTH` | 5,000 | per-text character length |
|
|
227
|
+
| `MAX_VECTORS` | 50,000 | `/reduce`, `/cluster` inputs |
|
|
228
|
+
| `MAX_URL_LENGTH` | 2,048 | `/ingest` url field |
|
|
229
|
+
| `MAX_INGEST_BODY_BYTES` | 10 MB | `/ingest` upstream body |
|
|
230
|
+
| `MAX_CLUSTERS_PER_SUMMARIZE` | 500 | `/summarize` cluster count |
|
|
231
|
+
| `MAX_REPS_PER_CLUSTER` | 100 | representatives per cluster |
|
|
232
|
+
|
|
233
|
+
Bump the constants in `user_intent_discovery/api/schemas.py` if a real workload legitimately needs more.
|
|
234
|
+
|
|
235
|
+
## Key parameters
|
|
236
|
+
|
|
237
|
+
| Parameter | Default | Notes |
|
|
238
|
+
|-----------|---------|-------|
|
|
239
|
+
| `min_cluster_size` | `5` | Smallest group that counts as a cluster. Lower it for small corpora. |
|
|
240
|
+
| `cluster_dim` | `5` | UMAP dimensions clustered on. |
|
|
241
|
+
| `umap_n_neighbors` | `15` | Lower = more local structure (helps on small datasets). |
|
|
242
|
+
| `cluster_selection_method` | `"eom"` | `"leaf"` yields more, finer clusters. |
|
|
243
|
+
| `top_n_per_cluster` | `5` | Representatives kept per cluster. |
|
|
244
|
+
| `top_k_clusters` | `None` | Limit to the K largest clusters. |
|
|
245
|
+
| `viz_dim` | `3` | Visualization dimensions; `0` skips it. |
|
|
246
|
+
| `random_state` | `42` | `None` = faster but non-reproducible. |
|
|
247
|
+
| `embedder`, `cluster_reducer`, `viz_reducer`, `clusterer`, `summarizer` | `None` | Inject any stage to override the local default. |
|
|
248
|
+
|
|
249
|
+
## Reproducibility vs. speed
|
|
250
|
+
|
|
251
|
+
By default `random_state=42`, so runs are reproducible but UMAP is single-threaded. Pass `random_state=None` for faster, multi-threaded, non-deterministic runs.
|
|
252
|
+
|
|
253
|
+
## Errors
|
|
254
|
+
|
|
255
|
+
Every exception derives from `ToolDiscoveryError` so a single `except` clause catches everything. Narrow subclasses: `EmbeddingError`, `ClusteringError`, `SummarizationError`, `OllamaConnectionError`. Over HTTP: `OllamaConnectionError` → 503, other `ToolDiscoveryError` → 500, schema violations → 422.
|
|
256
|
+
|
|
257
|
+
## Non-goals (v1)
|
|
258
|
+
|
|
259
|
+
To keep the first version focused: HDBSCAN only (no KMeans/Agglomerative); UMAP only (no PCA/t-SNE); Ollama only for summarization; embeddings are always computed in-package (no bring-your-own-vectors); nearest-centroid is the only noise-reassignment method; the API is stateless (no server-side sessions).
|
|
260
|
+
|
|
261
|
+
Planned for v2: text-source abstraction (pull questions from an authenticated endpoint or receive them via webhook), scheduled auto-runs, and a binary wire format for the remote stage clients.
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
git clone https://nithinvikasav@bitbucket.org/foxsense/user-intent-discovery.git
|
|
267
|
+
cd user-intent-discovery
|
|
268
|
+
poetry install --extras ui
|
|
269
|
+
poetry run pytest -m "not integration" # skip tests that need a running Ollama
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT — see [LICENSE](LICENSE).
|
|
275
|
+
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# user-intent-discovery
|
|
2
|
+
|
|
3
|
+
Cluster short texts (chatbot questions, feedback items, search queries — anything sentence-sized) by semantic similarity, surface the most representative items per cluster, and optionally synthesize each cluster into a single "general question" with a local LLM.
|
|
4
|
+
|
|
5
|
+
Ships as an importable **Python library**, an optional **FastAPI service with a browser UI**, and a set of **remote stage clients** — so any stage of the pipeline (embed, reduce, cluster, summarize) can run locally or against a different machine. Heavy stages on the GPU box, light ones on your laptop.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
embed -> reduce (UMAP) -> cluster (HDBSCAN) -> representatives -> metrics -> summarize (optional)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
1. **Embed** each text into a vector with `sentence-transformers`.
|
|
14
|
+
2. **Reduce** to a low-dimensional space with UMAP (density clustering works poorly in high dimensions).
|
|
15
|
+
3. **Cluster** with HDBSCAN, which finds clusters of varying density, labels outliers as noise, and does not require choosing the number of clusters up front.
|
|
16
|
+
4. **Extract representatives**: the N items closest to each cluster's center.
|
|
17
|
+
5. **Score** with silhouette, HDBSCAN persistence, DBCV, and noise ratio.
|
|
18
|
+
6. **Summarize** (optional): one general question per cluster via a local Ollama model.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install user-intent-discovery # library + remote clients
|
|
24
|
+
pip install "user-intent-discovery[ui]" # + FastAPI service and browser UI
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Python 3.10+.
|
|
28
|
+
|
|
29
|
+
## Library usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from user_intent_discovery import TopicClusterer
|
|
33
|
+
|
|
34
|
+
questions = [
|
|
35
|
+
"How do I reset my password?",
|
|
36
|
+
"I forgot my password, help",
|
|
37
|
+
"What are your business hours?",
|
|
38
|
+
"When are you open?",
|
|
39
|
+
# ...
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
clusterer = TopicClusterer(min_cluster_size=3)
|
|
43
|
+
result = clusterer.fit(questions)
|
|
44
|
+
|
|
45
|
+
print(f"Found {result.metrics.n_clusters} clusters "
|
|
46
|
+
f"({result.metrics.n_noise} noise points)")
|
|
47
|
+
|
|
48
|
+
for cluster_id in result.top_k_clusters:
|
|
49
|
+
info = result.clusters[cluster_id]
|
|
50
|
+
print(f"\nCluster {cluster_id} ({info.size} items):")
|
|
51
|
+
for rep in info.representatives:
|
|
52
|
+
print(f" - {rep.text}")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### With summarization
|
|
56
|
+
|
|
57
|
+
Summarization needs a running [Ollama](https://ollama.com) server:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from user_intent_discovery import TopicClusterer
|
|
61
|
+
from user_intent_discovery.summarization import OllamaSummarizer
|
|
62
|
+
|
|
63
|
+
clusterer = TopicClusterer(
|
|
64
|
+
min_cluster_size=3,
|
|
65
|
+
summarizer=OllamaSummarizer(model="llama3.2"),
|
|
66
|
+
)
|
|
67
|
+
result = clusterer.fit(questions)
|
|
68
|
+
|
|
69
|
+
for cluster_id, question in (result.summaries or {}).items():
|
|
70
|
+
print(f"Cluster {cluster_id}: {question}")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Split-compute across machines
|
|
74
|
+
|
|
75
|
+
The pipeline has four stages and each is behind a protocol (`Embedder`, `Reducer`, `Clusterer`, `Summarizer`). Every protocol has a local implementation and a remote one — `RemoteEmbedder`, `RemoteReducer`, `RemoteClusterer`, `RemoteSummarizer` — that talks to a running `user-intent-discovery` server. Any stage the developer doesn't inject falls back to the local default, so mix-and-match is free.
|
|
76
|
+
|
|
77
|
+
**All-remote** (laptop only orchestrates; every heavy stage runs elsewhere):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from user_intent_discovery import TopicClusterer, remote_stages
|
|
81
|
+
|
|
82
|
+
stages = remote_stages(
|
|
83
|
+
"http://gpu-box:8000",
|
|
84
|
+
cluster_dim=5, viz_dim=3,
|
|
85
|
+
min_cluster_size=3,
|
|
86
|
+
api_key="s3cret", # optional; matched against the server's --api-key
|
|
87
|
+
)
|
|
88
|
+
result = TopicClusterer(**stages).fit(questions)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Selective remote** (embed + summarize remote, UMAP + HDBSCAN local):
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from user_intent_discovery import TopicClusterer, RemoteEmbedder, RemoteSummarizer
|
|
95
|
+
|
|
96
|
+
clusterer = TopicClusterer(
|
|
97
|
+
embedder = RemoteEmbedder("http://gpu-box:8000"),
|
|
98
|
+
summarizer = RemoteSummarizer("http://gpu-box:8000", model="llama3.2"),
|
|
99
|
+
min_cluster_size=3, # UMAP + HDBSCAN kwargs still apply to local defaults
|
|
100
|
+
)
|
|
101
|
+
result = clusterer.fit(questions)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The wire format is JSON (nested float lists). It is fine for tens of thousands of items; a binary format is on the v2 roadmap.
|
|
105
|
+
|
|
106
|
+
### One-shot remote (no local pipeline)
|
|
107
|
+
|
|
108
|
+
If you only want to POST texts to a server and get the full result back — no injection, no mix-and-match — use `TopicClustererClient`:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from user_intent_discovery import TopicClustererClient
|
|
112
|
+
|
|
113
|
+
client = TopicClustererClient("http://gpu-box:8000")
|
|
114
|
+
result = client.fit(questions, min_cluster_size=3) # returns a dict
|
|
115
|
+
print(result["metrics"]["n_clusters"])
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It only depends on `requests`, so it's the right choice for machines that can't install the ML stack.
|
|
119
|
+
|
|
120
|
+
## Web service and UI
|
|
121
|
+
|
|
122
|
+
Installed with the `[ui]` extra:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
user-intent-discovery serve # http://127.0.0.1:8000
|
|
126
|
+
user-intent-discovery serve --port 9000 --ollama-model llama3.2
|
|
127
|
+
user-intent-discovery serve --no-summarizer # UI summarize tab greyed out
|
|
128
|
+
|
|
129
|
+
# production-ish
|
|
130
|
+
user-intent-discovery serve \
|
|
131
|
+
--host 0.0.0.0 --port 8000 \
|
|
132
|
+
--api-key "$(openssl rand -hex 16)" \
|
|
133
|
+
--cors-origin https://app.example.com \
|
|
134
|
+
--cors-origin https://staging.example.com
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Open the URL in a browser to paste questions, tune parameters, view the 3D cluster map, and summarize clusters.
|
|
138
|
+
|
|
139
|
+
### API endpoints
|
|
140
|
+
|
|
141
|
+
| Method | Path | Purpose |
|
|
142
|
+
|--------|--------------|----------------------------------------------|
|
|
143
|
+
| `POST` | `/ingest` | Fetch texts from an external URL server-side |
|
|
144
|
+
| `POST` | `/embed` | Texts to embedding vectors |
|
|
145
|
+
| `POST` | `/reduce` | Vectors to reduced coordinates |
|
|
146
|
+
| `POST` | `/cluster` | Coordinates to labels + metrics |
|
|
147
|
+
| `POST` | `/pipeline` | One-shot: texts to full result |
|
|
148
|
+
| `POST` | `/summarize` | Cluster representatives to a question |
|
|
149
|
+
| `GET` | `/health` | Status + `summarizer_available` + `auth_required` |
|
|
150
|
+
| `GET` | `/` | The browser UI |
|
|
151
|
+
|
|
152
|
+
The staged endpoints let other apps plug in at a single stage; `/pipeline` is the convenience path the UI uses.
|
|
153
|
+
|
|
154
|
+
### `/ingest` — fetch questions from your backend
|
|
155
|
+
|
|
156
|
+
The browser can't fetch cross-origin URLs (CORS) and can't inject auth headers safely, so this endpoint runs the fetch server-side and hands the extracted texts back. The UI's **Fetch from URL** button uses this route.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
curl -sX POST http://127.0.0.1:8000/ingest \
|
|
160
|
+
-H 'Content-Type: application/json' \
|
|
161
|
+
-d '{
|
|
162
|
+
"url": "https://chatbot.example.com/api/unanswered",
|
|
163
|
+
"headers": {"Authorization": "Bearer '"$CHATBOT_TOKEN"'"}
|
|
164
|
+
}'
|
|
165
|
+
# => {"texts": ["q1","q2",...], "count": 42}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Accepted upstream response shapes (auto-detected):
|
|
169
|
+
|
|
170
|
+
- JSON list of strings: `["q1", "q2"]`
|
|
171
|
+
- JSON list of objects, keys tried in order: `text`, `question`, `prompt`, `query`, `content`
|
|
172
|
+
- JSON object with a list under `questions` / `texts` / `prompts` / `queries` / `items` / `data`
|
|
173
|
+
- Plain text — split on newlines and commas
|
|
174
|
+
|
|
175
|
+
Errors: `400` for a non-http(s) URL, `413` if the upstream body exceeds 10 MB, `502` on any upstream network failure, `422` if the body has no extractable texts.
|
|
176
|
+
|
|
177
|
+
### Auth and CORS
|
|
178
|
+
|
|
179
|
+
- **`--api-key VALUE`** (or `USER_INTENT_DISCOVERY_API_KEY` env var). When set, every compute endpoint requires `X-API-Key: VALUE`. `/health` and `/` stay open, and `/health` reports `auth_required: true` so clients can adapt.
|
|
180
|
+
- **`--cors-origin URL`** (repeatable). Omit for permissive `*` (dev only); set explicit origins in production.
|
|
181
|
+
|
|
182
|
+
### Input caps
|
|
183
|
+
|
|
184
|
+
The server rejects over-large requests at the schema layer with HTTP 422, before any pipeline work:
|
|
185
|
+
|
|
186
|
+
| Cap | Default | Applies to |
|
|
187
|
+
|------------------------------|-----------|-------------------------------------|
|
|
188
|
+
| `MAX_TEXTS` | 20,000 | `/embed`, `/pipeline` text lists |
|
|
189
|
+
| `MAX_TEXT_LENGTH` | 5,000 | per-text character length |
|
|
190
|
+
| `MAX_VECTORS` | 50,000 | `/reduce`, `/cluster` inputs |
|
|
191
|
+
| `MAX_URL_LENGTH` | 2,048 | `/ingest` url field |
|
|
192
|
+
| `MAX_INGEST_BODY_BYTES` | 10 MB | `/ingest` upstream body |
|
|
193
|
+
| `MAX_CLUSTERS_PER_SUMMARIZE` | 500 | `/summarize` cluster count |
|
|
194
|
+
| `MAX_REPS_PER_CLUSTER` | 100 | representatives per cluster |
|
|
195
|
+
|
|
196
|
+
Bump the constants in `user_intent_discovery/api/schemas.py` if a real workload legitimately needs more.
|
|
197
|
+
|
|
198
|
+
## Key parameters
|
|
199
|
+
|
|
200
|
+
| Parameter | Default | Notes |
|
|
201
|
+
|-----------|---------|-------|
|
|
202
|
+
| `min_cluster_size` | `5` | Smallest group that counts as a cluster. Lower it for small corpora. |
|
|
203
|
+
| `cluster_dim` | `5` | UMAP dimensions clustered on. |
|
|
204
|
+
| `umap_n_neighbors` | `15` | Lower = more local structure (helps on small datasets). |
|
|
205
|
+
| `cluster_selection_method` | `"eom"` | `"leaf"` yields more, finer clusters. |
|
|
206
|
+
| `top_n_per_cluster` | `5` | Representatives kept per cluster. |
|
|
207
|
+
| `top_k_clusters` | `None` | Limit to the K largest clusters. |
|
|
208
|
+
| `viz_dim` | `3` | Visualization dimensions; `0` skips it. |
|
|
209
|
+
| `random_state` | `42` | `None` = faster but non-reproducible. |
|
|
210
|
+
| `embedder`, `cluster_reducer`, `viz_reducer`, `clusterer`, `summarizer` | `None` | Inject any stage to override the local default. |
|
|
211
|
+
|
|
212
|
+
## Reproducibility vs. speed
|
|
213
|
+
|
|
214
|
+
By default `random_state=42`, so runs are reproducible but UMAP is single-threaded. Pass `random_state=None` for faster, multi-threaded, non-deterministic runs.
|
|
215
|
+
|
|
216
|
+
## Errors
|
|
217
|
+
|
|
218
|
+
Every exception derives from `ToolDiscoveryError` so a single `except` clause catches everything. Narrow subclasses: `EmbeddingError`, `ClusteringError`, `SummarizationError`, `OllamaConnectionError`. Over HTTP: `OllamaConnectionError` → 503, other `ToolDiscoveryError` → 500, schema violations → 422.
|
|
219
|
+
|
|
220
|
+
## Non-goals (v1)
|
|
221
|
+
|
|
222
|
+
To keep the first version focused: HDBSCAN only (no KMeans/Agglomerative); UMAP only (no PCA/t-SNE); Ollama only for summarization; embeddings are always computed in-package (no bring-your-own-vectors); nearest-centroid is the only noise-reassignment method; the API is stateless (no server-side sessions).
|
|
223
|
+
|
|
224
|
+
Planned for v2: text-source abstraction (pull questions from an authenticated endpoint or receive them via webhook), scheduled auto-runs, and a binary wire format for the remote stage clients.
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
git clone https://nithinvikasav@bitbucket.org/foxsense/user-intent-discovery.git
|
|
230
|
+
cd user-intent-discovery
|
|
231
|
+
poetry install --extras ui
|
|
232
|
+
poetry run pytest -m "not integration" # skip tests that need a running Ollama
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "user-intent-discovery"
|
|
3
|
+
version = "1.1.0"
|
|
4
|
+
description = "Cluster chatbot questions by semantic similarity and surface the most representative ones, with optional local-LLM summarization. Each pipeline stage (embed, reduce, cluster, summarize) can be run locally or against a remote server."
|
|
5
|
+
authors = ["Nithin Vikas AV <nithinvikas.av@foxsense.io>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{ include = "user_intent_discovery", from = "src" }]
|
|
9
|
+
keywords = ["clustering", "embeddings", "umap", "hdbscan", "chatbot", "topic-modeling", "sentence-transformers", "ollama"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 5 - Production/Stable",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"Topic :: Text Processing :: Linguistic",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# TODO: fill in with your actual GitHub handle (no spaces) before publishing.
|
|
26
|
+
homepage = "https://bitbucket.org/foxsense/user-intent-discovery"
|
|
27
|
+
repository = "https://bitbucket.org/foxsense/user-intent-discovery"
|
|
28
|
+
documentation = "https://bitbucket.org/foxsense/user-intent-discovery"
|
|
29
|
+
|
|
30
|
+
[tool.poetry.dependencies]
|
|
31
|
+
python = ">=3.10,<4.0"
|
|
32
|
+
numpy = ">=1.24"
|
|
33
|
+
sentence-transformers = ">=2.5"
|
|
34
|
+
umap-learn = ">=0.5.5"
|
|
35
|
+
hdbscan = ">=0.8.33"
|
|
36
|
+
scikit-learn = ">=1.3"
|
|
37
|
+
requests = ">=2.31"
|
|
38
|
+
# --- ui extra (optional) ---
|
|
39
|
+
fastapi = { version = ">=0.110", optional = true }
|
|
40
|
+
uvicorn = { version = ">=0.29", extras = ["standard"], optional = true }
|
|
41
|
+
|
|
42
|
+
[tool.poetry.extras]
|
|
43
|
+
ui = ["fastapi", "uvicorn"]
|
|
44
|
+
|
|
45
|
+
[tool.poetry.group.dev.dependencies]
|
|
46
|
+
pytest = "*"
|
|
47
|
+
pytest-cov = "*"
|
|
48
|
+
black = "*"
|
|
49
|
+
isort = "*"
|
|
50
|
+
flake8 = "*"
|
|
51
|
+
mypy = "*"
|
|
52
|
+
pre-commit = "*"
|
|
53
|
+
sphinx = "*"
|
|
54
|
+
sphinx-rtd-theme = "*"
|
|
55
|
+
httpx = "*"
|
|
56
|
+
|
|
57
|
+
[tool.poetry.scripts]
|
|
58
|
+
user-intent-discovery = "user_intent_discovery.api.cli:main"
|
|
59
|
+
|
|
60
|
+
[build-system]
|
|
61
|
+
requires = ["poetry-core>=1.0.0"]
|
|
62
|
+
build-backend = "poetry.core.masonry.api"
|
|
63
|
+
|
|
64
|
+
# ---------------- tool configs ----------------
|
|
65
|
+
|
|
66
|
+
[tool.black]
|
|
67
|
+
line-length = 88
|
|
68
|
+
target-version = ["py310"]
|
|
69
|
+
|
|
70
|
+
[tool.isort]
|
|
71
|
+
profile = "black"
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
markers = [
|
|
76
|
+
"integration: tests needing a running Ollama server (skip with -m 'not integration')",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.coverage.run]
|
|
80
|
+
source = ["user_intent_discovery"]
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
python_version = "3.10"
|
|
84
|
+
strict = true
|
|
85
|
+
files = ["src"]
|
|
86
|
+
|
|
87
|
+
# Third-party libs ship no type stubs — don't let strict mode fail on them.
|
|
88
|
+
[[tool.mypy.overrides]]
|
|
89
|
+
module = ["umap.*", "hdbscan.*", "sentence_transformers.*", "sklearn.*"]
|
|
90
|
+
ignore_missing_imports = true
|
|
91
|
+
|
|
92
|
+
# Relax strictness on the test suite.
|
|
93
|
+
[[tool.mypy.overrides]]
|
|
94
|
+
module = "tests.*"
|
|
95
|
+
disallow_untyped_defs = false
|
|
96
|
+
disallow_incomplete_defs = false
|
|
@@ -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
|