Spaces:
Sleeping
fix: stop taste worlds getting near-identical names
Browse filesFour of seven worlds came back as permutations of Pop/Music/Electronic/Asian,
which made genuinely different groups unreadable. The clustering was fine; the
naming was not.
YouTube's topicCategories are extremely coarse -- Bollywood playback, K-pop and
Western EDM all return "Pop music, Music, Electronic music" -- and the prompt was
telling the model to use them, so it faithfully echoed near-identical inputs.
Now the channel names lead, generic topics are filtered out, topics are demoted to
a trailing hint, and the prompt asks for distinct names with worked examples.
Duplicate and reordered-duplicate names are rejected on the way back in, since two
worlds sharing a name is worse than falling back to channel labels.
Live result: "Bollywood Playback Singers", "Western Pop Artists", "IPL Cricket"
in place of "Asian, Pop, Music", "Pop, Electronic, Music", "Cricket, Sport".
- services/cluster_naming.py +58 -10
- tests/test_cluster_naming.py +51 -0
|
@@ -25,12 +25,30 @@ MAX_CHANNELS_SENT = 5 # enough to characterise a cluster, no more than need
|
|
| 25 |
MAX_NAME_LENGTH = 40
|
| 26 |
TIMEOUT = 30
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
SYSTEM_PROMPT = (
|
| 29 |
-
"You name groups of YouTube channels. For each numbered group you
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
|
|
@@ -40,21 +58,36 @@ def channel_label(channels: List[str]) -> str:
|
|
| 40 |
|
| 41 |
|
| 42 |
def _prompt(clusters: List[Dict], facts: Dict[str, Dict]) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
lines = []
|
| 44 |
for cluster in clusters:
|
| 45 |
names = cluster["channels"][:MAX_CHANNELS_SENT]
|
| 46 |
topics = []
|
| 47 |
for name in names:
|
| 48 |
for topic in (facts.get(name) or {}).get("topics", []):
|
| 49 |
-
if topic not in topics:
|
| 50 |
topics.append(topic)
|
| 51 |
-
line = f"{cluster['index']}:
|
| 52 |
if topics:
|
| 53 |
-
line += f"
|
| 54 |
lines.append(line)
|
| 55 |
return "\n".join(lines)
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def name_clusters(
|
| 59 |
clusters: List[Dict],
|
| 60 |
facts: Optional[Dict[str, Dict]],
|
|
@@ -112,11 +145,26 @@ def name_clusters(
|
|
| 112 |
|
| 113 |
names: Dict[int, str] = {}
|
| 114 |
valid = {c["index"] for c in clusters}
|
|
|
|
|
|
|
| 115 |
for key, value in (raw or {}).items():
|
| 116 |
try:
|
| 117 |
index = int(key)
|
| 118 |
except (TypeError, ValueError):
|
| 119 |
continue
|
| 120 |
-
if index in valid
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
return names
|
|
|
|
| 25 |
MAX_NAME_LENGTH = 40
|
| 26 |
TIMEOUT = 30
|
| 27 |
|
| 28 |
+
# Topics so broad they describe nearly every channel; they crowd out the useful ones.
|
| 29 |
+
GENERIC_TOPICS = {
|
| 30 |
+
"Music", "Entertainment", "Lifestyle (sociology)", "Society", "Hobby",
|
| 31 |
+
"Knowledge", "Film", "Performing arts",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# Ignored when deciding whether two names are really the same.
|
| 35 |
+
FILLER_WORDS = {"and", "the", "of", "a", "an", "your", "music"}
|
| 36 |
+
|
| 37 |
SYSTEM_PROMPT = (
|
| 38 |
+
"You name groups of YouTube channels. For each numbered group you get the channel "
|
| 39 |
+
"names, and sometimes the topic categories YouTube assigns them.\n\n"
|
| 40 |
+
"The channel names are the real signal. YouTube's topic categories are extremely "
|
| 41 |
+
"coarse -- Bollywood playback singers, K-pop and Western EDM all come back as "
|
| 42 |
+
"'Pop music, Music, Electronic music' -- so treat them as a weak hint and never "
|
| 43 |
+
"just echo them back.\n\n"
|
| 44 |
+
"Name each group for what those specific channels actually are: "
|
| 45 |
+
"'Bollywood Playback' rather than 'Asian, Pop, Music'; 'IPL Cricket' rather than "
|
| 46 |
+
"'Sport, Lifestyle'; 'Indian News' rather than 'Entertainment, Society'.\n\n"
|
| 47 |
+
"Every name must be DISTINCT from the others -- two groups sharing a name, or the "
|
| 48 |
+
"same words in a different order, makes the list unreadable. If two groups look "
|
| 49 |
+
"similar, find what separates them.\n\n"
|
| 50 |
+
"Reply with JSON only: an object mapping each group number (as a string) to a name "
|
| 51 |
+
"of at most four words, e.g. {\"0\": \"IPL Cricket\"}. No prose, no code fences."
|
| 52 |
)
|
| 53 |
|
| 54 |
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
def _prompt(clusters: List[Dict], facts: Dict[str, Dict]) -> str:
|
| 61 |
+
"""One line per group: channel names first, topics as a trailing hint.
|
| 62 |
+
|
| 63 |
+
Topics are collected per cluster but kept short. Deduplicating them across a
|
| 64 |
+
whole cluster is what flattened every music world into the same three words,
|
| 65 |
+
so the channel names lead and the taxonomy only ever supplements them.
|
| 66 |
+
"""
|
| 67 |
lines = []
|
| 68 |
for cluster in clusters:
|
| 69 |
names = cluster["channels"][:MAX_CHANNELS_SENT]
|
| 70 |
topics = []
|
| 71 |
for name in names:
|
| 72 |
for topic in (facts.get(name) or {}).get("topics", []):
|
| 73 |
+
if topic not in topics and topic not in GENERIC_TOPICS:
|
| 74 |
topics.append(topic)
|
| 75 |
+
line = f"{cluster['index']}: {', '.join(names)}"
|
| 76 |
if topics:
|
| 77 |
+
line += f" (hint: {', '.join(topics[:4])})"
|
| 78 |
lines.append(line)
|
| 79 |
return "\n".join(lines)
|
| 80 |
|
| 81 |
|
| 82 |
+
def _normalise(name: str) -> str:
|
| 83 |
+
"""A comparison key that treats reorderings as the same name.
|
| 84 |
+
|
| 85 |
+
"Pop, Electronic, Music" and "Electronic, Pop, Music" are not two names.
|
| 86 |
+
"""
|
| 87 |
+
words = re.findall(r"[a-z]+", name.lower())
|
| 88 |
+
return " ".join(sorted(w for w in words if w not in FILLER_WORDS))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
def name_clusters(
|
| 92 |
clusters: List[Dict],
|
| 93 |
facts: Optional[Dict[str, Dict]],
|
|
|
|
| 145 |
|
| 146 |
names: Dict[int, str] = {}
|
| 147 |
valid = {c["index"] for c in clusters}
|
| 148 |
+
seen: set = set()
|
| 149 |
+
|
| 150 |
for key, value in (raw or {}).items():
|
| 151 |
try:
|
| 152 |
index = int(key)
|
| 153 |
except (TypeError, ValueError):
|
| 154 |
continue
|
| 155 |
+
if index not in valid or not isinstance(value, str) or not value.strip():
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
name = value.strip()[:MAX_NAME_LENGTH]
|
| 159 |
+
signature = _normalise(name)
|
| 160 |
+
|
| 161 |
+
# A repeated name is worse than none: two worlds labelled the same cannot be
|
| 162 |
+
# told apart, and the channel-label fallback at least stays true. Drop the
|
| 163 |
+
# collision rather than show it.
|
| 164 |
+
if not signature or signature in seen:
|
| 165 |
+
continue
|
| 166 |
+
|
| 167 |
+
seen.add(signature)
|
| 168 |
+
names[index] = name
|
| 169 |
+
|
| 170 |
return names
|
|
@@ -168,3 +168,54 @@ def test_names_for_unknown_cluster_indexes_are_ignored():
|
|
| 168 |
result = name_clusters(CLUSTERS, FACTS, KEY, consented=True, session=session)
|
| 169 |
|
| 170 |
assert result == {0: "Cricket"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
result = name_clusters(CLUSTERS, FACTS, KEY, consented=True, session=session)
|
| 169 |
|
| 170 |
assert result == {0: "Cricket"}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# --- name quality ------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
def test_channels_are_presented_before_topics():
|
| 176 |
+
"""The channel names carry the signal; YouTube's topics are coarse.
|
| 177 |
+
|
| 178 |
+
Every music cluster comes back from YouTube as "Pop music, Music, Electronic
|
| 179 |
+
music" whether it is Bollywood playback or Western EDM, so a prompt that leads
|
| 180 |
+
with topics produces near-identical names for genuinely different worlds.
|
| 181 |
+
"""
|
| 182 |
+
from services.cluster_naming import _prompt
|
| 183 |
+
|
| 184 |
+
text = _prompt(CLUSTERS, FACTS)
|
| 185 |
+
first_line = text.splitlines()[0]
|
| 186 |
+
|
| 187 |
+
assert first_line.index("rajasthanroyals") < first_line.index("Cricket")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_the_prompt_asks_for_distinct_names():
|
| 191 |
+
from services.cluster_naming import SYSTEM_PROMPT
|
| 192 |
+
|
| 193 |
+
lowered = SYSTEM_PROMPT.lower()
|
| 194 |
+
assert "distinct" in lowered or "different from each other" in lowered
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_the_prompt_warns_that_topics_are_coarse():
|
| 198 |
+
"""Without this the model treats the taxonomy as authoritative and echoes it."""
|
| 199 |
+
from services.cluster_naming import SYSTEM_PROMPT
|
| 200 |
+
|
| 201 |
+
assert "coarse" in SYSTEM_PROMPT.lower() or "generic" in SYSTEM_PROMPT.lower()
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_duplicate_names_are_rejected():
|
| 205 |
+
"""Two worlds sharing a name is worse than no name -- the card cannot be read."""
|
| 206 |
+
session = FakeSession(reply('{"0": "Pop Music", "1": "Pop Music"}'))
|
| 207 |
+
|
| 208 |
+
result = name_clusters(CLUSTERS, FACTS, KEY, consented=True, session=session)
|
| 209 |
+
|
| 210 |
+
assert len(set(result.values())) == len(result), f"duplicate names survived: {result}"
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def test_near_duplicate_names_are_rejected():
|
| 214 |
+
""""Pop, Electronic, Music" and "Electronic, Pop, Music" are the same name."""
|
| 215 |
+
session = FakeSession(
|
| 216 |
+
reply('{"0": "Pop, Electronic, Music", "1": "Electronic, Pop, Music"}')
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
result = name_clusters(CLUSTERS, FACTS, KEY, consented=True, session=session)
|
| 220 |
+
|
| 221 |
+
assert len(result) <= 1, f"a reordering of the same words survived: {result}"
|