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.
Files changed (23) hide show
  1. user_intent_discovery-1.1.0/LICENSE +21 -0
  2. user_intent_discovery-1.1.0/PKG-INFO +275 -0
  3. user_intent_discovery-1.1.0/README.md +237 -0
  4. user_intent_discovery-1.1.0/pyproject.toml +96 -0
  5. user_intent_discovery-1.1.0/src/user_intent_discovery/__init__.py +46 -0
  6. user_intent_discovery-1.1.0/src/user_intent_discovery/api/__init__.py +0 -0
  7. user_intent_discovery-1.1.0/src/user_intent_discovery/api/app.py +53 -0
  8. user_intent_discovery-1.1.0/src/user_intent_discovery/api/cli.py +66 -0
  9. user_intent_discovery-1.1.0/src/user_intent_discovery/api/routes.py +279 -0
  10. user_intent_discovery-1.1.0/src/user_intent_discovery/api/schemas.py +138 -0
  11. user_intent_discovery-1.1.0/src/user_intent_discovery/api/static/index.html +656 -0
  12. user_intent_discovery-1.1.0/src/user_intent_discovery/caching.py +61 -0
  13. user_intent_discovery-1.1.0/src/user_intent_discovery/client.py +88 -0
  14. user_intent_discovery-1.1.0/src/user_intent_discovery/clustering.py +113 -0
  15. user_intent_discovery-1.1.0/src/user_intent_discovery/embedding.py +116 -0
  16. user_intent_discovery-1.1.0/src/user_intent_discovery/exceptions.py +26 -0
  17. user_intent_discovery-1.1.0/src/user_intent_discovery/metrics.py +91 -0
  18. user_intent_discovery-1.1.0/src/user_intent_discovery/pipeline.py +216 -0
  19. user_intent_discovery-1.1.0/src/user_intent_discovery/reduction.py +95 -0
  20. user_intent_discovery-1.1.0/src/user_intent_discovery/remote.py +384 -0
  21. user_intent_discovery-1.1.0/src/user_intent_discovery/representatives.py +132 -0
  22. user_intent_discovery-1.1.0/src/user_intent_discovery/results.py +109 -0
  23. 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"