Spaces:
Sleeping
fix(reducer): snippet-fallback labels + in-place /relabel endpoint
Browse filesCloses 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.
- app/api.py +54 -1
- app/cli.py +35 -1
- app/db.py +68 -0
- app/labeling.py +57 -11
- tests/test_labeling.py +25 -7
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
+
|
|
@@ -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],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
) -> str:
|
| 113 |
-
"""Title-cased label from top distinctive terms.
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -252,13 +252,13 @@ def test_label_clusters_degenerate_input_prefers_keyword_over_placeholder(
|
|
| 252 |
)
|
| 253 |
|
| 254 |
|
| 255 |
-
def
|
| 256 |
monkeypatch: pytest.MonkeyPatch,
|
| 257 |
) -> None:
|
| 258 |
-
# All-stopword
|
| 259 |
-
#
|
| 260 |
-
# "Cluster N"
|
| 261 |
-
#
|
| 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 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}"
|