cli-tools-kit 0.6.0__py3-none-any.whl
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.
- cli_tools_kit/__init__.py +59 -0
- cli_tools_kit/__main__.py +43 -0
- cli_tools_kit/advertise.py +77 -0
- cli_tools_kit/cron_installer.py +159 -0
- cli_tools_kit/gui_installer.py +5928 -0
- cli_tools_kit/host.py +239 -0
- cli_tools_kit/identity.py +229 -0
- cli_tools_kit/onboarding.py +261 -0
- cli_tools_kit/skills.py +97 -0
- cli_tools_kit/sources.py +335 -0
- cli_tools_kit/taxonomy/__init__.py +40 -0
- cli_tools_kit/taxonomy/build.py +171 -0
- cli_tools_kit/taxonomy/capability.py +116 -0
- cli_tools_kit/taxonomy/cluster.py +261 -0
- cli_tools_kit/taxonomy/corpus.py +268 -0
- cli_tools_kit/taxonomy/embedder.py +170 -0
- cli_tools_kit/taxonomy/groups.py +108 -0
- cli_tools_kit/taxonomy/llm_groups.py +748 -0
- cli_tools_kit/tool_installer.py +572 -0
- cli_tools_kit/tui_installer.py +462 -0
- cli_tools_kit-0.6.0.dist-info/METADATA +505 -0
- cli_tools_kit-0.6.0.dist-info/RECORD +26 -0
- cli_tools_kit-0.6.0.dist-info/WHEEL +5 -0
- cli_tools_kit-0.6.0.dist-info/entry_points.txt +3 -0
- cli_tools_kit-0.6.0.dist-info/licenses/LICENSE +21 -0
- cli_tools_kit-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Group a tool tree into named bands by what the tools are for.
|
|
2
|
+
|
|
3
|
+
The installer bands its rows by *something*. ``group_by="capability"`` uses the
|
|
4
|
+
one word each tool advertises, which needs no computation. This package is the
|
|
5
|
+
other option: it reads what a tree already documents about itself and produces
|
|
6
|
+
a small set of named categories, which is what you want once a tree has more
|
|
7
|
+
tools than a person can scan.
|
|
8
|
+
|
|
9
|
+
Three tiers, tried in order, so the feature degrades instead of failing:
|
|
10
|
+
|
|
11
|
+
1. **LLM** — name six categories, file every tool into them, then review the
|
|
12
|
+
result and let the model move misfits and rename a category to match what it
|
|
13
|
+
actually holds. Uses Gemini when ``GEMINI_API_KEY`` is reachable, otherwise a
|
|
14
|
+
local LM Studio / Ollama server. This is the tier that produces good names.
|
|
15
|
+
2. **capability** — band the advertised capability words into a fixed set of
|
|
16
|
+
six. No network, no model, instant, and the names are stable.
|
|
17
|
+
3. **embed + k-means** — embed the documents and cluster them, naming each
|
|
18
|
+
cluster after its most distinctive token. Names are poor ("git", "clipboard")
|
|
19
|
+
but the tree still opens.
|
|
20
|
+
|
|
21
|
+
:func:`~cli_tools_kit.taxonomy.groups.ensure_groups` is the entry point a
|
|
22
|
+
wrapper's discoverer calls. It fingerprints the tree's documents and rebuilds
|
|
23
|
+
only when they changed, so the common case costs a couple of milliseconds.
|
|
24
|
+
|
|
25
|
+
from cli_tools_kit.taxonomy import ensure_groups
|
|
26
|
+
groups = ensure_groups(root) # {tool_name: band label}
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .embedder import EmbeddingUnavailable, embed_texts
|
|
30
|
+
from .groups import ensure_groups, load_groups
|
|
31
|
+
from .build import build_groups, DEFAULT_K
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"EmbeddingUnavailable",
|
|
35
|
+
"embed_texts",
|
|
36
|
+
"ensure_groups",
|
|
37
|
+
"load_groups",
|
|
38
|
+
"build_groups",
|
|
39
|
+
"DEFAULT_K",
|
|
40
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Compute the tool grouping and write data/tool_groups.json.
|
|
2
|
+
|
|
3
|
+
The one place that decides the bands and writes the file. regroup.py calls it
|
|
4
|
+
to rebuild on demand; groups.ensure_groups calls it when the installer starts
|
|
5
|
+
and the stored grouping no longer matches the corpus.
|
|
6
|
+
|
|
7
|
+
Three ways to get there, tried in order:
|
|
8
|
+
|
|
9
|
+
1. **gemini** — reads a condensed blurb per tool and returns named categories
|
|
10
|
+
with their members. The normal path: naming a category is a language task.
|
|
11
|
+
2. **capability**, when a grouping is already stored — keep those bands and
|
|
12
|
+
their names, and file only the tools missing from them by the capability
|
|
13
|
+
word they advertise. No network. Preferred over re-embedding because it
|
|
14
|
+
preserves names a model chose over names tf-idf would invent.
|
|
15
|
+
3. **embed** — no stored grouping to extend, so cluster the full documents and
|
|
16
|
+
name each cluster after its most distinctive token. Needs an embedding
|
|
17
|
+
backend; if that is missing too, fall back to capability words alone.
|
|
18
|
+
|
|
19
|
+
Only the first needs an API key, and only steps 1 and 3 need a network. A
|
|
20
|
+
distributed checkout ships with data/tool_groups.json already written, so it
|
|
21
|
+
never reaches any of them until its owner adds a tool of their own.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import datetime
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import tempfile
|
|
28
|
+
from typing import Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
from .capability import capability_groups
|
|
31
|
+
from .cluster import cluster_documents
|
|
32
|
+
from .corpus import (
|
|
33
|
+
corpus_fingerprint,
|
|
34
|
+
tool_blurbs,
|
|
35
|
+
tool_capabilities,
|
|
36
|
+
tool_documents,
|
|
37
|
+
)
|
|
38
|
+
from .embedder import EmbeddingUnavailable, backend_name, embed_texts
|
|
39
|
+
from .groups import groups_path, read_groups_file
|
|
40
|
+
from .llm_groups import (
|
|
41
|
+
LLMGroupingUnavailable,
|
|
42
|
+
active_label,
|
|
43
|
+
llm_groups,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
DEFAULT_K = 6
|
|
47
|
+
DEFAULT_SEED = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_groups(
|
|
51
|
+
root: str,
|
|
52
|
+
k: int = DEFAULT_K,
|
|
53
|
+
seed: int = DEFAULT_SEED,
|
|
54
|
+
write: bool = True,
|
|
55
|
+
method: str = "auto",
|
|
56
|
+
budget: Optional[float] = None,
|
|
57
|
+
rename: bool = False,
|
|
58
|
+
) -> dict:
|
|
59
|
+
"""Decide the grouping and (unless write=False) store it.
|
|
60
|
+
|
|
61
|
+
``budget`` caps the LLM tier in seconds — the installer passes one, a
|
|
62
|
+
deliberate ``regroup.py`` run does not. ``rename`` throws the stored
|
|
63
|
+
category names away and asks for six fresh ones.
|
|
64
|
+
|
|
65
|
+
``method`` is "auto" (the chain described above), or one tier by name:
|
|
66
|
+
"llm", "embed" or "capability". Raises ValueError when the tree holds no
|
|
67
|
+
tools, and EmbeddingUnavailable / LLMGroupingUnavailable when a single tier
|
|
68
|
+
was asked for and could not run. Existing overrides are carried over
|
|
69
|
+
untouched.
|
|
70
|
+
"""
|
|
71
|
+
from .corpus import tool_dirs
|
|
72
|
+
|
|
73
|
+
names = tool_dirs(root)
|
|
74
|
+
if not names:
|
|
75
|
+
raise ValueError("no tools found")
|
|
76
|
+
previous = read_groups_file(root)
|
|
77
|
+
stored = previous.get("labels") or {}
|
|
78
|
+
categories = None if rename else previous.get("categories")
|
|
79
|
+
if not isinstance(categories, list) or len(categories) != k:
|
|
80
|
+
categories = None
|
|
81
|
+
|
|
82
|
+
labels: Dict[str, List[str]] = {}
|
|
83
|
+
used = ""
|
|
84
|
+
if method in ("auto", "llm"):
|
|
85
|
+
try:
|
|
86
|
+
labels = llm_groups(
|
|
87
|
+
tool_blurbs(root), k, budget=budget, categories=categories
|
|
88
|
+
)
|
|
89
|
+
used = active_label() or "llm"
|
|
90
|
+
except LLMGroupingUnavailable:
|
|
91
|
+
if method == "llm":
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
if not labels and method in ("auto", "capability"):
|
|
95
|
+
if stored or method == "capability":
|
|
96
|
+
labels = capability_groups(tool_capabilities(root), stored or None)
|
|
97
|
+
used = "capability" + (":extended" if stored else "")
|
|
98
|
+
|
|
99
|
+
if not labels and method in ("auto", "embed"):
|
|
100
|
+
try:
|
|
101
|
+
labels = _embed_groups(root, names, k, seed)
|
|
102
|
+
used = f"embed-kmeans:{backend_name()}"
|
|
103
|
+
except EmbeddingUnavailable:
|
|
104
|
+
if method == "embed":
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
if not labels:
|
|
108
|
+
labels = capability_groups(tool_capabilities(root), stored or None)
|
|
109
|
+
used = "capability"
|
|
110
|
+
|
|
111
|
+
payload = {
|
|
112
|
+
"generated": datetime.date.today().isoformat(),
|
|
113
|
+
"k": k,
|
|
114
|
+
"seed": seed,
|
|
115
|
+
"method": used,
|
|
116
|
+
"categories": _carry_scopes(labels, categories),
|
|
117
|
+
"fingerprint": corpus_fingerprint(root),
|
|
118
|
+
"labels": labels,
|
|
119
|
+
"overrides": read_groups_file(root).get("overrides") or {},
|
|
120
|
+
}
|
|
121
|
+
if write:
|
|
122
|
+
write_groups_file(root, payload)
|
|
123
|
+
return payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _carry_scopes(
|
|
127
|
+
labels: Dict[str, List[str]], categories: Optional[List[dict]]
|
|
128
|
+
) -> List[dict]:
|
|
129
|
+
"""The band names to reuse next time, keeping the scope of each survivor.
|
|
130
|
+
|
|
131
|
+
A band the review step renamed keeps its members but loses its scope; that
|
|
132
|
+
is fine, the scope only steers the next assignment.
|
|
133
|
+
"""
|
|
134
|
+
scopes = {c.get("name"): c.get("scope", "") for c in (categories or [])}
|
|
135
|
+
return [{"name": name, "scope": scopes.get(name, "")} for name in labels]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _embed_groups(
|
|
139
|
+
root: str, names: List[str], k: int, seed: int
|
|
140
|
+
) -> Dict[str, List[str]]:
|
|
141
|
+
"""Offline fallback: embed the full documents and k-means them."""
|
|
142
|
+
docs = tool_documents(root)
|
|
143
|
+
ordered = sorted(docs)
|
|
144
|
+
vectors = embed_texts([docs[n] for n in ordered])
|
|
145
|
+
groups: List[Tuple[str, List[str]]] = cluster_documents(
|
|
146
|
+
ordered, vectors, docs, k, seed=seed
|
|
147
|
+
)
|
|
148
|
+
return {label: members for label, members in groups}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def write_groups_file(root: str, payload: dict) -> str:
|
|
152
|
+
"""Write the payload atomically so a concurrent reader sees whole JSON."""
|
|
153
|
+
path = groups_path(root)
|
|
154
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
155
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
|
|
156
|
+
try:
|
|
157
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
158
|
+
json.dump(payload, fh, indent=2, ensure_ascii=False)
|
|
159
|
+
fh.write("\n")
|
|
160
|
+
os.replace(tmp, path)
|
|
161
|
+
except BaseException:
|
|
162
|
+
try:
|
|
163
|
+
os.unlink(tmp)
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
raise
|
|
167
|
+
return path
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["DEFAULT_K", "DEFAULT_SEED", "build_groups", "write_groups_file",
|
|
171
|
+
"EmbeddingUnavailable", "LLMGroupingUnavailable"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Group tools by the capability word they advertise. No network, no model.
|
|
2
|
+
|
|
3
|
+
The last tier of the grouping chain, and the only one that works on a host with
|
|
4
|
+
no API key and no local embedding server — which is every host but this fleet's,
|
|
5
|
+
since the key is read from the repo's own .env. A distributed checkout gets its
|
|
6
|
+
bands from the committed data/tool_groups.json; this is what happens when
|
|
7
|
+
someone then adds a tool of their own.
|
|
8
|
+
|
|
9
|
+
Two modes. With an existing grouping it is *incremental*: the stored bands and
|
|
10
|
+
their names are kept exactly as they are, and only tools missing from them are
|
|
11
|
+
filed, next to whichever band already holds their capability. Without one it
|
|
12
|
+
builds the bands from DEFAULT_BANDS below.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
16
|
+
|
|
17
|
+
# Every word in validate_structure.CAPABILITY_VOCAB has a home here. A tool
|
|
18
|
+
# whose capability is missing or outside the vocabulary lands in the smallest
|
|
19
|
+
# band, which is the honest answer: nothing about it says where it belongs.
|
|
20
|
+
DEFAULT_BANDS: Dict[str, Tuple[str, ...]] = {
|
|
21
|
+
"AI & Chat": ("agent", "llm-chat", "chat"),
|
|
22
|
+
"Audio & Speech": ("tts", "stt", "audio-visualizer"),
|
|
23
|
+
"Vision & Creation": ("image-gen", "image-edit", "ocr", "vision-search"),
|
|
24
|
+
"Media & Publishing": (
|
|
25
|
+
"scrape", "summarize", "download", "media-launcher", "publish",
|
|
26
|
+
"pdf-extract",
|
|
27
|
+
),
|
|
28
|
+
"Developer Tools": (
|
|
29
|
+
"commit-gen", "dep-check", "forge", "repo-hygiene", "finetune",
|
|
30
|
+
),
|
|
31
|
+
"System & Context": ("smarthome", "vnc-display", "context-picker"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_BAND_OF_CAPABILITY = {
|
|
35
|
+
capability: band
|
|
36
|
+
for band, capabilities in DEFAULT_BANDS.items()
|
|
37
|
+
for capability in capabilities
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _smallest(labels: Dict[str, List[str]]) -> str:
|
|
42
|
+
"""Name of the least populated band, ties broken alphabetically."""
|
|
43
|
+
return min(labels, key=lambda band: (len(labels[band]), band))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def capability_groups(
|
|
47
|
+
capabilities: Dict[str, str],
|
|
48
|
+
existing: Optional[Dict[str, Sequence[str]]] = None,
|
|
49
|
+
) -> Dict[str, List[str]]:
|
|
50
|
+
"""Band name -> members, decided by each tool's capability word.
|
|
51
|
+
|
|
52
|
+
``existing`` is a stored grouping to extend rather than replace: its band
|
|
53
|
+
names survive untouched, tools that no longer exist are dropped, and only
|
|
54
|
+
the newcomers are placed.
|
|
55
|
+
"""
|
|
56
|
+
if not capabilities:
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
if existing:
|
|
60
|
+
labels = {
|
|
61
|
+
band: [tool for tool in members if tool in capabilities]
|
|
62
|
+
for band, members in existing.items()
|
|
63
|
+
}
|
|
64
|
+
labels = {band: members for band, members in labels.items() if members}
|
|
65
|
+
else:
|
|
66
|
+
labels = {}
|
|
67
|
+
|
|
68
|
+
if not labels:
|
|
69
|
+
for tool, capability in capabilities.items():
|
|
70
|
+
band = _BAND_OF_CAPABILITY.get(capability)
|
|
71
|
+
if band:
|
|
72
|
+
labels.setdefault(band, []).append(tool)
|
|
73
|
+
if not labels:
|
|
74
|
+
labels = {"AI & Chat": []}
|
|
75
|
+
|
|
76
|
+
# Which band already holds each capability, and each family of related
|
|
77
|
+
# capabilities, by weight of numbers.
|
|
78
|
+
home: Dict[str, Dict[str, int]] = {}
|
|
79
|
+
family: Dict[str, Dict[str, int]] = {}
|
|
80
|
+
|
|
81
|
+
def _record(capability: str, band: str) -> None:
|
|
82
|
+
if not capability:
|
|
83
|
+
return
|
|
84
|
+
home.setdefault(capability, {})
|
|
85
|
+
home[capability][band] = home[capability].get(band, 0) + 1
|
|
86
|
+
default = _BAND_OF_CAPABILITY.get(capability)
|
|
87
|
+
if default:
|
|
88
|
+
family.setdefault(default, {})
|
|
89
|
+
family[default][band] = family[default].get(band, 0) + 1
|
|
90
|
+
|
|
91
|
+
for band, members in labels.items():
|
|
92
|
+
for tool in members:
|
|
93
|
+
_record(capabilities.get(tool, ""), band)
|
|
94
|
+
|
|
95
|
+
def _pick(counts: Dict[str, int]) -> str:
|
|
96
|
+
return max(counts, key=lambda band: (counts[band], band))
|
|
97
|
+
|
|
98
|
+
for tool in sorted(capabilities):
|
|
99
|
+
if any(tool in members for members in labels.values()):
|
|
100
|
+
continue
|
|
101
|
+
capability = capabilities[tool]
|
|
102
|
+
default = _BAND_OF_CAPABILITY.get(capability)
|
|
103
|
+
if home.get(capability):
|
|
104
|
+
# A band already holds this exact capability.
|
|
105
|
+
band = _pick(home[capability])
|
|
106
|
+
elif default and family.get(default):
|
|
107
|
+
# No exact match, but a band holds its siblings: stt joins tts.
|
|
108
|
+
band = _pick(family[default])
|
|
109
|
+
elif default in labels:
|
|
110
|
+
band = default
|
|
111
|
+
else:
|
|
112
|
+
band = _smallest(labels)
|
|
113
|
+
labels[band].append(tool)
|
|
114
|
+
_record(capability, band)
|
|
115
|
+
|
|
116
|
+
return {band: sorted(members) for band, members in labels.items() if members}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Deterministic k-means over normalised vectors, plus group labelling.
|
|
2
|
+
|
|
3
|
+
Pure python, no numpy, so the result is identical on every host. Vectors are
|
|
4
|
+
L2-normalised, which makes squared euclidean distance a stand-in for cosine
|
|
5
|
+
distance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
from typing import Dict, List, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
STOPWORDS = {
|
|
14
|
+
"a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at",
|
|
15
|
+
"back", "be", "because", "been", "before", "being", "below", "between",
|
|
16
|
+
"both", "but", "by", "can", "claude", "code", "com", "d", "de", "did", "do",
|
|
17
|
+
"does", "doing", "done", "each", "etc", "even", "every", "few", "file",
|
|
18
|
+
"files", "for", "from", "get", "gets", "github", "had", "has", "have", "he",
|
|
19
|
+
"her", "here", "how", "http", "https", "i", "if", "in", "install", "into",
|
|
20
|
+
"is", "it", "its", "just", "like", "made", "main", "make", "makes", "many",
|
|
21
|
+
"md", "more", "most", "must", "my", "need", "needs", "new", "no", "not",
|
|
22
|
+
"now", "of", "off", "on", "once", "one", "only", "or", "other", "our",
|
|
23
|
+
"out", "over", "own", "path", "py", "python", "readme", "repo", "run",
|
|
24
|
+
"runs", "same", "script", "see", "set", "she", "should", "since", "so",
|
|
25
|
+
"some", "still", "such", "than", "that", "the", "their", "them", "then",
|
|
26
|
+
"there", "these", "they", "this", "those", "through", "to", "too", "tool",
|
|
27
|
+
"tools", "two", "under", "until", "up", "us", "use", "used", "uses",
|
|
28
|
+
"using", "very", "was", "way", "we", "were", "what", "when", "where",
|
|
29
|
+
"python3", "json", "name", "names", "default", "defaults", "example",
|
|
30
|
+
"examples", "args", "arg", "note", "notes", "directory", "dir", "output",
|
|
31
|
+
"which", "while", "who", "why", "will", "with", "would", "you", "your",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize(vectors: Sequence[Sequence[float]]) -> List[List[float]]:
|
|
38
|
+
"""Scale every vector to unit length."""
|
|
39
|
+
out = []
|
|
40
|
+
for vec in vectors:
|
|
41
|
+
norm = math.sqrt(sum(x * x for x in vec))
|
|
42
|
+
if norm == 0.0:
|
|
43
|
+
out.append([0.0] * len(vec))
|
|
44
|
+
else:
|
|
45
|
+
out.append([x / norm for x in vec])
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sqdist(a: Sequence[float], b: Sequence[float]) -> float:
|
|
50
|
+
return sum((x - y) * (x - y) for x, y in zip(a, b))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _kmeans_plusplus(vectors: List[List[float]], k: int, rng: random.Random) -> List[List[float]]:
|
|
54
|
+
"""Pick k starting centroids, spread out, using the given rng."""
|
|
55
|
+
first = rng.randrange(len(vectors))
|
|
56
|
+
centroids = [list(vectors[first])]
|
|
57
|
+
while len(centroids) < k:
|
|
58
|
+
dists = [min(_sqdist(v, c) for c in centroids) for v in vectors]
|
|
59
|
+
total = sum(dists)
|
|
60
|
+
if total <= 0.0:
|
|
61
|
+
centroids.append(list(vectors[rng.randrange(len(vectors))]))
|
|
62
|
+
continue
|
|
63
|
+
target = rng.random() * total
|
|
64
|
+
running = 0.0
|
|
65
|
+
chosen = len(vectors) - 1
|
|
66
|
+
for i, d in enumerate(dists):
|
|
67
|
+
running += d
|
|
68
|
+
if running >= target:
|
|
69
|
+
chosen = i
|
|
70
|
+
break
|
|
71
|
+
centroids.append(list(vectors[chosen]))
|
|
72
|
+
return centroids
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _assign(vectors: List[List[float]], centroids: List[List[float]]) -> List[int]:
|
|
76
|
+
"""Assign each vector to its nearest centroid."""
|
|
77
|
+
labels = []
|
|
78
|
+
for vec in vectors:
|
|
79
|
+
best, best_d = 0, None
|
|
80
|
+
for ci, cen in enumerate(centroids):
|
|
81
|
+
d = _sqdist(vec, cen)
|
|
82
|
+
if best_d is None or d < best_d:
|
|
83
|
+
best, best_d = ci, d
|
|
84
|
+
labels.append(best)
|
|
85
|
+
return labels
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _assign_balanced(
|
|
89
|
+
vectors: List[List[float]], centroids: List[List[float]], cap: int
|
|
90
|
+
) -> List[int]:
|
|
91
|
+
"""Assign vectors nearest-first, never letting a cluster exceed cap."""
|
|
92
|
+
pairs = []
|
|
93
|
+
for vi, vec in enumerate(vectors):
|
|
94
|
+
for ci, cen in enumerate(centroids):
|
|
95
|
+
pairs.append((_sqdist(vec, cen), vi, ci))
|
|
96
|
+
pairs.sort()
|
|
97
|
+
|
|
98
|
+
labels = [-1] * len(vectors)
|
|
99
|
+
sizes = [0] * len(centroids)
|
|
100
|
+
placed = 0
|
|
101
|
+
for _, vi, ci in pairs:
|
|
102
|
+
if labels[vi] != -1 or sizes[ci] >= cap:
|
|
103
|
+
continue
|
|
104
|
+
labels[vi] = ci
|
|
105
|
+
sizes[ci] += 1
|
|
106
|
+
placed += 1
|
|
107
|
+
if placed == len(vectors):
|
|
108
|
+
break
|
|
109
|
+
# Anything left over (all its clusters full) goes to the emptiest cluster.
|
|
110
|
+
for vi, lab in enumerate(labels):
|
|
111
|
+
if lab == -1:
|
|
112
|
+
ci = min(range(len(centroids)), key=lambda c: (sizes[c], c))
|
|
113
|
+
labels[vi] = ci
|
|
114
|
+
sizes[ci] += 1
|
|
115
|
+
return labels
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _recentre(
|
|
119
|
+
vectors: List[List[float]], labels: List[int], centroids: List[List[float]]
|
|
120
|
+
) -> List[List[float]]:
|
|
121
|
+
"""Move each centroid to the mean of its members, normalised."""
|
|
122
|
+
dim = len(vectors[0])
|
|
123
|
+
sums = [[0.0] * dim for _ in centroids]
|
|
124
|
+
counts = [0] * len(centroids)
|
|
125
|
+
for vec, lab in zip(vectors, labels):
|
|
126
|
+
counts[lab] += 1
|
|
127
|
+
row = sums[lab]
|
|
128
|
+
for i, x in enumerate(vec):
|
|
129
|
+
row[i] += x
|
|
130
|
+
new = []
|
|
131
|
+
for ci, count in enumerate(counts):
|
|
132
|
+
if count == 0:
|
|
133
|
+
new.append(list(centroids[ci]))
|
|
134
|
+
else:
|
|
135
|
+
new.append([x / count for x in sums[ci]])
|
|
136
|
+
return normalize(new)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def kmeans(
|
|
140
|
+
vectors: Sequence[Sequence[float]],
|
|
141
|
+
k: int,
|
|
142
|
+
seed: int = 0,
|
|
143
|
+
iters: int = 50,
|
|
144
|
+
balanced: bool = True,
|
|
145
|
+
) -> List[int]:
|
|
146
|
+
"""Cluster vectors into k groups and return one cluster index per vector.
|
|
147
|
+
|
|
148
|
+
With balanced=True no cluster grows past ceil(N/k)+1 members.
|
|
149
|
+
"""
|
|
150
|
+
points = normalize(vectors)
|
|
151
|
+
n = len(points)
|
|
152
|
+
if n == 0:
|
|
153
|
+
return []
|
|
154
|
+
k = max(1, min(k, n))
|
|
155
|
+
cap = math.ceil(n / k) + 1
|
|
156
|
+
|
|
157
|
+
rng = random.Random(seed)
|
|
158
|
+
centroids = _kmeans_plusplus(points, k, rng)
|
|
159
|
+
|
|
160
|
+
labels = []
|
|
161
|
+
for _ in range(iters):
|
|
162
|
+
if balanced:
|
|
163
|
+
new_labels = _assign_balanced(points, centroids, cap)
|
|
164
|
+
else:
|
|
165
|
+
new_labels = _assign(points, centroids)
|
|
166
|
+
if new_labels == labels:
|
|
167
|
+
break
|
|
168
|
+
labels = new_labels
|
|
169
|
+
centroids = _recentre(points, labels, centroids)
|
|
170
|
+
return labels
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def tokenize(text: str) -> List[str]:
|
|
174
|
+
"""Lowercase alphanumeric tokens, stopwords and 1-2 char words dropped."""
|
|
175
|
+
return [
|
|
176
|
+
t
|
|
177
|
+
for t in _TOKEN_RE.findall(text.lower())
|
|
178
|
+
if len(t) > 2 and t not in STOPWORDS and not t.isdigit()
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def label_group(
|
|
183
|
+
group_docs: Sequence[str], other_docs: Sequence[str], top: int = 1
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Name a group by its most distinctive tokens, joined with ' · '.
|
|
186
|
+
|
|
187
|
+
``top`` is how many tokens the name may carry; one by default.
|
|
188
|
+
"""
|
|
189
|
+
if not group_docs:
|
|
190
|
+
return "Ungrouped"
|
|
191
|
+
|
|
192
|
+
def doc_freq(docs):
|
|
193
|
+
counts = {}
|
|
194
|
+
for doc in docs:
|
|
195
|
+
for token in set(tokenize(doc)):
|
|
196
|
+
counts[token] = counts.get(token, 0) + 1
|
|
197
|
+
return counts
|
|
198
|
+
|
|
199
|
+
def term_freq(docs):
|
|
200
|
+
counts = {}
|
|
201
|
+
for doc in docs:
|
|
202
|
+
for token in tokenize(doc):
|
|
203
|
+
counts[token] = counts.get(token, 0) + 1
|
|
204
|
+
return counts
|
|
205
|
+
|
|
206
|
+
inside_df = doc_freq(group_docs)
|
|
207
|
+
inside_tf = term_freq(group_docs)
|
|
208
|
+
outside_df = doc_freq(other_docs)
|
|
209
|
+
n_in = len(group_docs)
|
|
210
|
+
n_out = max(1, len(other_docs))
|
|
211
|
+
|
|
212
|
+
# A token found in only one document of a multi-document group names that
|
|
213
|
+
# document, not the group, so require two documents whenever we can.
|
|
214
|
+
min_docs = 2 if n_in > 2 else 1
|
|
215
|
+
|
|
216
|
+
scored = []
|
|
217
|
+
for token, docs_with in inside_df.items():
|
|
218
|
+
if docs_with < min_docs:
|
|
219
|
+
continue
|
|
220
|
+
tf = inside_tf[token] / n_in
|
|
221
|
+
spread = docs_with / n_in
|
|
222
|
+
idf = math.log((n_out + 1) / (outside_df.get(token, 0) + 1)) + 1.0
|
|
223
|
+
scored.append((-(tf * spread * idf), token))
|
|
224
|
+
scored.sort()
|
|
225
|
+
picked = [token for _, token in scored[:top]]
|
|
226
|
+
return " · ".join(picked) if picked else "Ungrouped"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cluster_documents(
|
|
230
|
+
names: Sequence[str],
|
|
231
|
+
vectors: Sequence[Sequence[float]],
|
|
232
|
+
docs: Dict[str, str],
|
|
233
|
+
k: int,
|
|
234
|
+
seed: int = 0,
|
|
235
|
+
balanced: bool = True,
|
|
236
|
+
top: int = 1,
|
|
237
|
+
) -> List[Tuple[str, List[str]]]:
|
|
238
|
+
"""Cluster the tools and return (label, members) per group."""
|
|
239
|
+
labels = kmeans(vectors, k, seed=seed, balanced=balanced)
|
|
240
|
+
buckets: Dict[int, List[str]] = {}
|
|
241
|
+
for name, lab in zip(names, labels):
|
|
242
|
+
buckets.setdefault(lab, []).append(name)
|
|
243
|
+
|
|
244
|
+
groups = []
|
|
245
|
+
for lab in sorted(buckets):
|
|
246
|
+
members = sorted(buckets[lab])
|
|
247
|
+
inside = [docs.get(m, "") for m in members]
|
|
248
|
+
outside = [docs.get(n, "") for n in names if n not in set(members)]
|
|
249
|
+
groups.append((label_group(inside, outside, top=top), members))
|
|
250
|
+
|
|
251
|
+
# Two clusters can land on the same tokens; keep the labels unique.
|
|
252
|
+
seen = {}
|
|
253
|
+
unique = []
|
|
254
|
+
for label, members in groups:
|
|
255
|
+
if label in seen:
|
|
256
|
+
seen[label] += 1
|
|
257
|
+
label = f"{label} ({seen[label]})"
|
|
258
|
+
else:
|
|
259
|
+
seen[label] = 1
|
|
260
|
+
unique.append((label, members))
|
|
261
|
+
return unique
|