scythe410 commited on
Commit
6a730c6
Β·
1 Parent(s): 40f24e1

fix(reducer): snippet-fallback labels + in-place /relabel endpoint

Browse files

Closes the gap where free_label_from_terms could still bottom out at
"Cluster N" when the c-TF-IDF vocabulary collapsed entirely (URLs,
numbers, non-alpha tokens). The labeler now passes the medoid snippet
into the free path too β€” previously snippets only fed the opt-in LLM
upgrade β€” and falls back to a Title-Cased, sanitized fragment of that
snippet when terms are empty. The bare numeric placeholder only
survives the case where every text in the cluster is genuinely empty.

Adds POST /relabel/{project_id} and a matching `uv run python -m
app.cli relabel <id>` subcommand so projects already persisted with
placeholder labels can be repaired without re-embedding. The route
runs through the existing reducer secret + Starlette threadpool; the
CLI talks Postgres directly so label repair works even when the
reducer service isn't live.

Files changed (5) hide show
  1. app/api.py +54 -1
  2. app/cli.py +35 -1
  3. app/db.py +68 -0
  4. app/labeling.py +57 -11
  5. tests/test_labeling.py +25 -7
app/api.py CHANGED
@@ -16,7 +16,16 @@ from pydantic import BaseModel, Field
16
 
17
  from .auth import verify_reducer_secret
18
  from .config import ASYNC_ROW_THRESHOLD, DEFAULT_EMBED_MODEL, DEFAULT_REDUCER, REDIS_URL
19
- from .db import connect, ensure_project, fetch_status, set_status, write_results
 
 
 
 
 
 
 
 
 
20
  from .pipeline import run_pipeline
21
  from .progress import get_progress
22
 
@@ -161,6 +170,50 @@ async def embed_reduce(req: EmbedReduceRequest) -> EmbedReduceResponse:
161
  return EmbedReduceResponse(**summary, mode="sync")
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  @router.get(
165
  "/status/{project_id}",
166
  response_model=StatusResponse,
 
16
 
17
  from .auth import verify_reducer_secret
18
  from .config import ASYNC_ROW_THRESHOLD, DEFAULT_EMBED_MODEL, DEFAULT_REDUCER, REDIS_URL
19
+ from .db import (
20
+ connect,
21
+ ensure_project,
22
+ fetch_points_for_labeling,
23
+ fetch_status,
24
+ set_status,
25
+ update_cluster_labels,
26
+ write_results,
27
+ )
28
+ from .labeling import label_clusters
29
  from .pipeline import run_pipeline
30
  from .progress import get_progress
31
 
 
170
  return EmbedReduceResponse(**summary, mode="sync")
171
 
172
 
173
+ class RelabelResponse(BaseModel):
174
+ project_id: str
175
+ n_clusters: int
176
+ n_updated: int
177
+ labels: dict[int, str]
178
+
179
+
180
+ @router.post(
181
+ "/relabel/{project_id}",
182
+ response_model=RelabelResponse,
183
+ dependencies=[Depends(verify_reducer_secret)],
184
+ )
185
+ async def relabel(project_id: str) -> RelabelResponse:
186
+ """Recompute cluster labels for an existing project without re-embedding.
187
+
188
+ Pulls texts + cluster_ids straight from the points table, runs the
189
+ free c-TF-IDF (and optional LLM upgrade) labeler, and writes the result
190
+ back to clusters.label. Used to repair projects whose labels were
191
+ persisted as 'Cluster N' placeholders by an older reducer build.
192
+ """
193
+
194
+ def _do_work() -> tuple[int, int, dict[int, str]]:
195
+ with connect() as conn:
196
+ texts, cluster_ids, snippets = fetch_points_for_labeling(conn, project_id)
197
+ if not texts:
198
+ raise HTTPException(
199
+ status_code=404,
200
+ detail=f"project {project_id} has no points (or does not exist)",
201
+ )
202
+ labels = label_clusters(
203
+ texts, cluster_ids, medoid_snippets_by_cluster=snippets or None
204
+ )
205
+ updated = update_cluster_labels(conn, project_id, labels)
206
+ return len(labels), updated, labels
207
+
208
+ n_clusters, n_updated, labels = await anyio.to_thread.run_sync(_do_work)
209
+ return RelabelResponse(
210
+ project_id=project_id,
211
+ n_clusters=n_clusters,
212
+ n_updated=n_updated,
213
+ labels=labels,
214
+ )
215
+
216
+
217
  @router.get(
218
  "/status/{project_id}",
219
  response_model=StatusResponse,
app/cli.py CHANGED
@@ -11,7 +11,15 @@ import pandas as pd
11
  import typer
12
 
13
  from .config import DEFAULT_EMBED_MODEL, DEFAULT_REDUCER
14
- from .db import connect, ensure_project, set_status, write_results
 
 
 
 
 
 
 
 
15
  from .pipeline import run_pipeline
16
 
17
  cli = typer.Typer(add_completion=False, no_args_is_help=True)
@@ -200,6 +208,32 @@ def bake_static(
200
  typer.echo(f"wrote : {out_path} ({size_mb:.2f} MiB)")
201
 
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  def main() -> None:
204
  cli()
205
 
 
11
  import typer
12
 
13
  from .config import DEFAULT_EMBED_MODEL, DEFAULT_REDUCER
14
+ from .db import (
15
+ connect,
16
+ ensure_project,
17
+ fetch_points_for_labeling,
18
+ set_status,
19
+ update_cluster_labels,
20
+ write_results,
21
+ )
22
+ from .labeling import label_clusters
23
  from .pipeline import run_pipeline
24
 
25
  cli = typer.Typer(add_completion=False, no_args_is_help=True)
 
208
  typer.echo(f"wrote : {out_path} ({size_mb:.2f} MiB)")
209
 
210
 
211
+ @cli.command()
212
+ def relabel(
213
+ project_id: str = typer.Argument(..., help="UUID of project to relabel"), # noqa: B008
214
+ ) -> None:
215
+ """Recompute and persist cluster labels for an existing project.
216
+
217
+ Reads texts + cluster_ids straight from points, runs c-TF-IDF (+ optional
218
+ LLM upgrade if OPENAI_API_KEY is set), and updates clusters.label in place.
219
+ No embedding work, no point/edge writes β€” just label repair.
220
+ """
221
+ with connect() as conn:
222
+ texts, cluster_ids, snippets = fetch_points_for_labeling(conn, project_id)
223
+ if not texts:
224
+ typer.echo(f"project {project_id}: no points (or missing)")
225
+ raise typer.Exit(code=1)
226
+ labels = label_clusters(
227
+ texts, cluster_ids, medoid_snippets_by_cluster=snippets or None
228
+ )
229
+ updated = update_cluster_labels(conn, project_id, labels)
230
+ typer.echo(f"project {project_id}")
231
+ typer.echo(f" clusters : {len(labels)}")
232
+ typer.echo(f" updated : {updated}")
233
+ for cid, lbl in sorted(labels.items()):
234
+ typer.echo(f" {cid:>3}: {lbl}")
235
+
236
+
237
  def main() -> None:
238
  cli()
239
 
app/db.py CHANGED
@@ -251,3 +251,71 @@ def write_results(
251
  }
252
 
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  }
252
 
253
 
254
+ def fetch_points_for_labeling(
255
+ conn: psycopg.Connection, project_id: str
256
+ ) -> tuple[list[str], np.ndarray, dict[int, list[str]]]:
257
+ """Return (texts, cluster_ids, medoid_snippets_by_cluster) for a project.
258
+
259
+ Used by the relabel path to recompute labels in place without re-embedding.
260
+ `medoid_snippets_by_cluster` is built from each cluster's recorded
261
+ medoid_point_id (preferred) with a high-probability fallback so the LLM
262
+ upgrade path still has decent input even on older rows where the medoid
263
+ text isn't otherwise distinguished.
264
+ """
265
+ rows = conn.execute(
266
+ """
267
+ select text, cluster_id
268
+ from public.points
269
+ where project_id = %s
270
+ order by id
271
+ """,
272
+ (project_id,),
273
+ ).fetchall()
274
+ texts: list[str] = []
275
+ cids: list[int] = []
276
+ for text, cid in rows:
277
+ texts.append(text or "")
278
+ cids.append(int(cid) if cid is not None else -1)
279
+
280
+ snippets: dict[int, list[str]] = {}
281
+ medoid_rows = conn.execute(
282
+ """
283
+ select c.cluster_id, p.text
284
+ from public.clusters c
285
+ left join public.points p on p.id = c.medoid_point_id
286
+ where c.project_id = %s
287
+ """,
288
+ (project_id,),
289
+ ).fetchall()
290
+ for cid, text in medoid_rows:
291
+ if text:
292
+ snippets.setdefault(int(cid), []).append(text)
293
+
294
+ return texts, np.array(cids, dtype=np.int32), snippets
295
+
296
+
297
+ def update_cluster_labels(
298
+ conn: psycopg.Connection, project_id: str, labels: dict[int, str]
299
+ ) -> int:
300
+ """Overwrite cluster.label for the given (project_id, cluster_id) pairs.
301
+ Returns the number of rows updated. Tenant scoping is enforced by the
302
+ project_id filter; this helper runs with the service-role connection so
303
+ RLS is not in play here β€” callers must already have authorized the project.
304
+ """
305
+ if not labels:
306
+ return 0
307
+ updated = 0
308
+ with conn.cursor() as cur:
309
+ for cid, label in labels.items():
310
+ cur.execute(
311
+ """
312
+ update public.clusters
313
+ set label = %s
314
+ where project_id = %s and cluster_id = %s
315
+ """,
316
+ (label, project_id, int(cid)),
317
+ )
318
+ updated += cur.rowcount or 0
319
+ return updated
320
+
321
+
app/labeling.py CHANGED
@@ -33,6 +33,7 @@ LABEL_LLM_MODEL = "gpt-4o-mini"
33
 
34
  _LEAD_LABEL_RE = re.compile(r"^(label|topic|title|name)\s*[:\-]\s*", re.IGNORECASE)
35
  _TRAIL_PUNCT = ".,;:!?-—–\"'`"
 
36
 
37
 
38
  def _texts_by_cluster(texts: list[str], cluster_ids: np.ndarray) -> dict[int, list[str]]:
@@ -107,18 +108,57 @@ def ctfidf_terms(
107
  return out
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def free_label_from_terms(
111
- terms: list[str], cluster_id: int, max_chars: int = LABEL_MAX_CHARS, max_terms: int = 3
 
 
 
 
 
112
  ) -> str:
113
- """Title-cased label from top distinctive terms. Falls back to 'Cluster N'."""
114
- if not terms:
115
- return f"Cluster {cluster_id}"
116
- for n in range(min(max_terms, len(terms)), 0, -1):
117
- label = " ".join(t.capitalize() for t in terms[:n])
118
- if len(label) <= max_chars:
119
- return label
120
- # Even one term is too long β€” truncate hard.
121
- return terms[0].capitalize()[: max_chars - 1] + "…"
 
 
 
 
 
 
 
122
 
123
 
124
  def _clean_llm_label(raw: str, max_chars: int, max_words: int) -> str | None:
@@ -235,7 +275,13 @@ def label_clusters(
235
 
236
  labels: dict[int, str] = {}
237
  for cid in grouped:
238
- labels[cid] = free_label_from_terms(top_terms.get(cid, []), cid)
 
 
 
 
 
 
239
 
240
  if config.OPENAI_API_KEY and medoid_snippets_by_cluster:
241
  for cid in grouped:
 
33
 
34
  _LEAD_LABEL_RE = re.compile(r"^(label|topic|title|name)\s*[:\-]\s*", re.IGNORECASE)
35
  _TRAIL_PUNCT = ".,;:!?-—–\"'`"
36
+ _WORDLIKE_RE = re.compile(r"\S+")
37
 
38
 
39
  def _texts_by_cluster(texts: list[str], cluster_ids: np.ndarray) -> dict[int, list[str]]:
 
108
  return out
109
 
110
 
111
+ def _snippet_label(snippet: str | None, max_chars: int, max_words: int) -> str | None:
112
+ """Title-cased fragment of a representative snippet, used as the very last
113
+ resort when c-TF-IDF produced nothing usable (vocabulary collapsed to
114
+ stopwords, URLs, numbers, or single characters). Returns None when the
115
+ snippet itself has nothing word-like to show.
116
+ """
117
+ if not snippet:
118
+ return None
119
+ s = _sanitize(snippet).strip()
120
+ if not s:
121
+ return None
122
+ words = _WORDLIKE_RE.findall(s)
123
+ if not words:
124
+ return None
125
+ picked: list[str] = []
126
+ for w in words[: max_words * 2]:
127
+ picked.append(w)
128
+ if len(" ".join(picked)) >= max_chars or len(picked) >= max_words:
129
+ break
130
+ label = " ".join(picked).rstrip(_TRAIL_PUNCT).strip()
131
+ if not label:
132
+ return None
133
+ if len(label) > max_chars:
134
+ label = label[: max_chars - 1].rstrip() + "…"
135
+ return " ".join(w.capitalize() if w.isalpha() else w for w in label.split())
136
+
137
+
138
  def free_label_from_terms(
139
+ terms: list[str],
140
+ cluster_id: int,
141
+ *,
142
+ snippet: str | None = None,
143
+ max_chars: int = LABEL_MAX_CHARS,
144
+ max_terms: int = 3,
145
  ) -> str:
146
+ """Title-cased label from top distinctive terms. When `terms` is empty,
147
+ falls back to a sanitized fragment of `snippet` (the cluster medoid). Only
148
+ if both are unusable do we return the bare 'Cluster N' β€” and that branch
149
+ should be unreachable in practice now that the medoid is always passed in.
150
+ """
151
+ if terms:
152
+ for n in range(min(max_terms, len(terms)), 0, -1):
153
+ label = " ".join(t.capitalize() for t in terms[:n])
154
+ if len(label) <= max_chars:
155
+ return label
156
+ return terms[0].capitalize()[: max_chars - 1] + "…"
157
+
158
+ fallback = _snippet_label(snippet, max_chars, LABEL_MAX_WORDS)
159
+ if fallback:
160
+ return fallback
161
+ return f"Cluster {cluster_id}"
162
 
163
 
164
  def _clean_llm_label(raw: str, max_chars: int, max_words: int) -> str | None:
 
275
 
276
  labels: dict[int, str] = {}
277
  for cid in grouped:
278
+ snippet = None
279
+ if medoid_snippets_by_cluster:
280
+ snips = medoid_snippets_by_cluster.get(cid) or []
281
+ snippet = snips[0] if snips else None
282
+ if snippet is None and grouped.get(cid):
283
+ snippet = grouped[cid][0]
284
+ labels[cid] = free_label_from_terms(top_terms.get(cid, []), cid, snippet=snippet)
285
 
286
  if config.OPENAI_API_KEY and medoid_snippets_by_cluster:
287
  for cid in grouped:
tests/test_labeling.py CHANGED
@@ -252,13 +252,13 @@ def test_label_clusters_degenerate_input_prefers_keyword_over_placeholder(
252
  )
253
 
254
 
255
- def test_label_clusters_truly_unlabelable_keeps_placeholder(
256
  monkeypatch: pytest.MonkeyPatch,
257
  ) -> None:
258
- # All-stopword/punctuation input β†’ CountVectorizer raises ValueError on
259
- # empty vocabulary β†’ ctfidf_terms returns {c: []} β†’ free fallback to
260
- # "Cluster N". This is the ONLY case where the numeric placeholder is
261
- # acceptable.
262
  monkeypatch.setattr(config, "OPENAI_API_KEY", "")
263
 
264
  texts = ["the and of"] * 5 + ["a an in"] * 5
@@ -266,7 +266,25 @@ def test_label_clusters_truly_unlabelable_keeps_placeholder(
266
 
267
  labels = labeling.label_clusters(texts, cluster_ids)
268
  assert set(labels.keys()) == {0, 1}
269
- # Documenting the contract: numeric placeholders only appear when truly
270
- # no keyword can be extracted (e.g. empty vocabulary after stopwording).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  for cid, lbl in labels.items():
272
  assert lbl == f"Cluster {cid}"
 
252
  )
253
 
254
 
255
+ def test_label_clusters_stopword_only_falls_back_to_snippet(
256
  monkeypatch: pytest.MonkeyPatch,
257
  ) -> None:
258
+ # All-stopword input β†’ CountVectorizer raises ValueError on empty
259
+ # vocabulary β†’ ctfidf_terms returns {c: []}. Old behavior was to drop
260
+ # to bare "Cluster N"; new contract is to use the medoid snippet so the
261
+ # user still sees *something* from their own text in the title card.
262
  monkeypatch.setattr(config, "OPENAI_API_KEY", "")
263
 
264
  texts = ["the and of"] * 5 + ["a an in"] * 5
 
266
 
267
  labels = labeling.label_clusters(texts, cluster_ids)
268
  assert set(labels.keys()) == {0, 1}
269
+ for cid, lbl in labels.items():
270
+ assert lbl, f"cluster {cid} produced an empty label"
271
+ assert lbl != f"Cluster {cid}", (
272
+ f"cluster {cid} fell through to the bare numeric placeholder"
273
+ )
274
+
275
+
276
+ def test_label_clusters_only_unusable_text_returns_placeholder(
277
+ monkeypatch: pytest.MonkeyPatch,
278
+ ) -> None:
279
+ # The single remaining case where the numeric placeholder is acceptable:
280
+ # every text in the cluster is genuinely empty after sanitization, so
281
+ # neither c-TF-IDF nor the snippet fallback has anything to show.
282
+ monkeypatch.setattr(config, "OPENAI_API_KEY", "")
283
+
284
+ texts = ["", " ", "\t\t"] + ["", " "]
285
+ cluster_ids = np.array([0, 0, 0, 1, 1], dtype=np.int32)
286
+
287
+ labels = labeling.label_clusters(texts, cluster_ids)
288
+ assert set(labels.keys()) == {0, 1}
289
  for cid, lbl in labels.items():
290
  assert lbl == f"Cluster {cid}"