smartpipe-cli 1.3.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.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
smartpipe/verbs/chart.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The ``chart`` verb: NDJSON in, a bar chart out. Free — no model calls.
|
|
2
|
+
|
|
3
|
+
The chart IS the result, so it goes to stdout; ``--save`` additionally writes a
|
|
4
|
+
dependency-free SVG. Counts a field across records (or tallies whole lines),
|
|
5
|
+
which makes it the natural tail for the tools upstream:
|
|
6
|
+
|
|
7
|
+
… | smartpipe map "Extract {label}" | smartpipe chart label
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol
|
|
15
|
+
|
|
16
|
+
from smartpipe.core.errors import ExitCode, UsageFault
|
|
17
|
+
from smartpipe.engine.chart import render_bars, render_svg, render_svg_panels
|
|
18
|
+
from smartpipe.io import diagnostics
|
|
19
|
+
from smartpipe.io.items import item_from_line
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TextIO
|
|
24
|
+
|
|
25
|
+
__all__ = ["ChartRequest", "run_chart"]
|
|
26
|
+
|
|
27
|
+
_DEFAULT_TOP = 20
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ChartRequest:
|
|
32
|
+
field: str | None = None # None: tally whole lines
|
|
33
|
+
top: int | None = None
|
|
34
|
+
save: Path | None = None
|
|
35
|
+
title: str | None = None
|
|
36
|
+
facets: tuple[str, ...] = () # --facet a,b,c: several panels, one pass
|
|
37
|
+
by_time: str | None = None # --by-time FIELD:BUCKET — chronological bars (D38/13)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ChartContext(Protocol):
|
|
41
|
+
"""chart needs nothing from the container — the Protocol keeps the shape."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_chart(request: ChartRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
45
|
+
if request.facets and request.field is not None:
|
|
46
|
+
raise UsageFault("--facet replaces the FIELD argument — pass one or the other")
|
|
47
|
+
if request.by_time is not None and (request.facets or request.field is not None):
|
|
48
|
+
raise UsageFault("--by-time replaces FIELD/--facet — pass one of the three")
|
|
49
|
+
if request.by_time is not None:
|
|
50
|
+
return _run_by_time(request, stdin=stdin, stdout=stdout)
|
|
51
|
+
if request.facets:
|
|
52
|
+
return _run_facets(request, stdin=stdin, stdout=stdout)
|
|
53
|
+
counts: Counter[str] = Counter()
|
|
54
|
+
for index, line in enumerate(stdin):
|
|
55
|
+
if not line.strip():
|
|
56
|
+
continue
|
|
57
|
+
item = item_from_line(line, index)
|
|
58
|
+
if request.field is not None:
|
|
59
|
+
value = item.data.get(request.field) if item.data is not None else None
|
|
60
|
+
counts["(missing)" if value is None else str(value)] += 1
|
|
61
|
+
else:
|
|
62
|
+
counts[item.text.strip()] += 1
|
|
63
|
+
ranked = counts.most_common(request.top or _DEFAULT_TOP)
|
|
64
|
+
dropped = len(counts) - len(ranked)
|
|
65
|
+
stdout.write(render_bars(ranked) + "\n")
|
|
66
|
+
if dropped > 0:
|
|
67
|
+
diagnostics.note(f"{dropped} more values below the top {len(ranked)} (--top widens)")
|
|
68
|
+
if request.save is not None:
|
|
69
|
+
request.save.write_text(
|
|
70
|
+
render_svg(ranked, title=request.title or request.field), encoding="utf-8"
|
|
71
|
+
)
|
|
72
|
+
diagnostics.note(f"chart saved: {request.save} (SVG — opens anywhere, converts to png)")
|
|
73
|
+
return ExitCode.OK
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_facets(request: ChartRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
77
|
+
tallies: dict[str, Counter[str]] = {facet: Counter() for facet in request.facets}
|
|
78
|
+
for index, line in enumerate(stdin):
|
|
79
|
+
if not line.strip():
|
|
80
|
+
continue
|
|
81
|
+
item = item_from_line(line, index)
|
|
82
|
+
for facet, counter in tallies.items():
|
|
83
|
+
value = item.data.get(facet) if item.data is not None else None
|
|
84
|
+
counter["(missing)" if value is None else str(value)] += 1
|
|
85
|
+
limit = request.top or _DEFAULT_TOP
|
|
86
|
+
panels: list[tuple[str, list[tuple[str, int]]]] = []
|
|
87
|
+
for facet, counter in tallies.items():
|
|
88
|
+
ranked = counter.most_common(limit)
|
|
89
|
+
panels.append((facet, ranked))
|
|
90
|
+
dropped = len(counter) - len(ranked)
|
|
91
|
+
if dropped > 0:
|
|
92
|
+
diagnostics.note(f"{facet}: {dropped} more values below the top {len(ranked)}")
|
|
93
|
+
rule_width = 46
|
|
94
|
+
from smartpipe.cli.screens import heading
|
|
95
|
+
|
|
96
|
+
sections = [
|
|
97
|
+
heading(f"── {facet} {'─' * max(1, rule_width - len(facet) - 4)}")
|
|
98
|
+
+ "\n"
|
|
99
|
+
+ render_bars(ranked)
|
|
100
|
+
for facet, ranked in panels
|
|
101
|
+
]
|
|
102
|
+
stdout.write("\n".join(sections) + "\n")
|
|
103
|
+
if request.save is not None:
|
|
104
|
+
request.save.write_text(render_svg_panels(panels, title=request.title), encoding="utf-8")
|
|
105
|
+
diagnostics.note(f"chart saved: {request.save} (SVG — opens anywhere, converts to png)")
|
|
106
|
+
return ExitCode.OK
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run_by_time(request: ChartRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
110
|
+
from smartpipe.engine.timebin import bucket_label, parse_bucket, parse_timestamp
|
|
111
|
+
|
|
112
|
+
assert request.by_time is not None
|
|
113
|
+
field, colon, bucket_text = request.by_time.partition(":")
|
|
114
|
+
if not colon or not field.strip():
|
|
115
|
+
raise UsageFault(
|
|
116
|
+
"--by-time takes FIELD:BUCKET — e.g. --by-time ts:1h\n"
|
|
117
|
+
" Buckets: 1m · 5m · 15m · 1h · 6h · 1d"
|
|
118
|
+
)
|
|
119
|
+
bucket = parse_bucket(bucket_text)
|
|
120
|
+
field = field.strip()
|
|
121
|
+
counts: Counter[int] = Counter()
|
|
122
|
+
unparseable = 0
|
|
123
|
+
for index, line in enumerate(stdin):
|
|
124
|
+
if not line.strip():
|
|
125
|
+
continue
|
|
126
|
+
item = item_from_line(line, index)
|
|
127
|
+
value = item.data.get(field) if item.data is not None else None
|
|
128
|
+
epoch = parse_timestamp(value)
|
|
129
|
+
if epoch is None:
|
|
130
|
+
unparseable += 1
|
|
131
|
+
continue
|
|
132
|
+
counts[int(epoch // bucket) * bucket] += 1
|
|
133
|
+
if not counts:
|
|
134
|
+
stdout.write("(nothing to chart)\n")
|
|
135
|
+
else:
|
|
136
|
+
# chronological, zero-filled: gaps in a time series are signal
|
|
137
|
+
first, last = min(counts), max(counts)
|
|
138
|
+
rows = [
|
|
139
|
+
(bucket_label(float(moment), bucket), counts.get(moment, 0))
|
|
140
|
+
for moment in range(first, last + bucket, bucket)
|
|
141
|
+
]
|
|
142
|
+
stdout.write(render_bars(rows) + "\n")
|
|
143
|
+
if request.save is not None:
|
|
144
|
+
request.save.write_text(
|
|
145
|
+
render_svg(rows, title=request.title or field), encoding="utf-8"
|
|
146
|
+
)
|
|
147
|
+
diagnostics.note(f"chart saved: {request.save} (SVG — opens anywhere, converts to png)")
|
|
148
|
+
if unparseable:
|
|
149
|
+
diagnostics.note(
|
|
150
|
+
f"{unparseable:,} rows with unparseable '{field}' — "
|
|
151
|
+
"ISO-8601 or epoch only; preprocess with jq/date"
|
|
152
|
+
)
|
|
153
|
+
return ExitCode.OK
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The ``cluster`` verb: group by meaning, label each group (D38/05).
|
|
2
|
+
|
|
3
|
+
KQL's ``autocluster``/``reduce by`` done semantically: N embeddings + one
|
|
4
|
+
label call per cluster — never N chat calls. The Monday slide (P3), the
|
|
5
|
+
phishing lure families (P6), and the qualitative codebook (P10) as one verb.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol
|
|
13
|
+
|
|
14
|
+
from smartpipe.core.errors import ExitCode, ItemError, UsageFault
|
|
15
|
+
from smartpipe.engine.chunking import mean_pool
|
|
16
|
+
from smartpipe.engine.clustering import adaptive_threshold, leader_clusters, merge_to_k
|
|
17
|
+
from smartpipe.engine.ranking import cosine
|
|
18
|
+
from smartpipe.engine.runner import Done, FailurePolicy
|
|
19
|
+
from smartpipe.engine.schema import validate_and_coerce
|
|
20
|
+
from smartpipe.io import diagnostics, readers
|
|
21
|
+
from smartpipe.io.inputs import STDIN
|
|
22
|
+
from smartpipe.io.items import describe_source
|
|
23
|
+
from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
|
|
24
|
+
from smartpipe.models.base import ChatModel, CompletionRequest
|
|
25
|
+
from smartpipe.verbs.common import embed_in_batches
|
|
26
|
+
from smartpipe.verbs.convert import make_converter
|
|
27
|
+
from smartpipe.verbs.embed import optional_chat
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from typing import TextIO
|
|
31
|
+
|
|
32
|
+
from smartpipe.io.inputs import InputSpec
|
|
33
|
+
from smartpipe.io.items import Item
|
|
34
|
+
from smartpipe.models.base import EmbeddingModel, ModelRef
|
|
35
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
36
|
+
|
|
37
|
+
__all__ = ["ClusterRequest", "label_cluster", "run_cluster"]
|
|
38
|
+
|
|
39
|
+
_EXAMPLES = 3
|
|
40
|
+
|
|
41
|
+
_LABEL_SCHEMA: dict[str, object] = {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"label": {"type": "string", "description": "3-6 words naming what unites the items"}
|
|
45
|
+
},
|
|
46
|
+
"required": ["label"],
|
|
47
|
+
"additionalProperties": False,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_LABEL_SYSTEM = (
|
|
51
|
+
"You name clusters of similar items. Reply with a short, specific label "
|
|
52
|
+
"(3-6 words) capturing what unites them — content, not form."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class ClusterRequest:
|
|
58
|
+
k: int | None = None
|
|
59
|
+
top: int | None = None
|
|
60
|
+
explode: str | None = None # "members" is the only value
|
|
61
|
+
model_flag: str | None = None # chat, for labels
|
|
62
|
+
embed_flag: str | None = None
|
|
63
|
+
concurrency_flag: int | None = None
|
|
64
|
+
allow_captions: bool = False
|
|
65
|
+
input: InputSpec = STDIN
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ClusterContext(Protocol):
|
|
69
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
|
|
70
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
71
|
+
async def embedding_model(self, flag: str | None = None) -> EmbeddingModel: ...
|
|
72
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def run_cluster(
|
|
76
|
+
request: ClusterRequest,
|
|
77
|
+
context: ClusterContext,
|
|
78
|
+
*,
|
|
79
|
+
stdin: TextIO,
|
|
80
|
+
stdout: TextIO,
|
|
81
|
+
stop: asyncio.Event | None = None,
|
|
82
|
+
) -> ExitCode:
|
|
83
|
+
if request.explode is not None and request.explode != "members":
|
|
84
|
+
raise UsageFault("--explode takes exactly 'members' (one row per input item)")
|
|
85
|
+
if request.k is not None and request.k < 1:
|
|
86
|
+
raise UsageFault("--k needs a positive cluster count")
|
|
87
|
+
model = await context.embedding_model(request.embed_flag)
|
|
88
|
+
items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
89
|
+
items = [item async for item in items_iter]
|
|
90
|
+
if not items:
|
|
91
|
+
return ExitCode.OK
|
|
92
|
+
diagnostics.note(
|
|
93
|
+
f"cluster: ~{len(items):,} embeddings + one label call per cluster (typically < 20)"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
log = diagnostics.DegradationLog()
|
|
97
|
+
converter_chat = await optional_chat(context)
|
|
98
|
+
converter = make_converter(
|
|
99
|
+
converter_chat,
|
|
100
|
+
allow_paid=request.allow_captions,
|
|
101
|
+
log=log,
|
|
102
|
+
stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
|
|
103
|
+
)
|
|
104
|
+
clustered_items: list[Item] = []
|
|
105
|
+
vectors: list[tuple[float, ...]] = []
|
|
106
|
+
outcomes = embed_in_batches(
|
|
107
|
+
model,
|
|
108
|
+
items,
|
|
109
|
+
failure_policy=FailurePolicy(),
|
|
110
|
+
stop=stop,
|
|
111
|
+
log=log,
|
|
112
|
+
converter=converter,
|
|
113
|
+
)
|
|
114
|
+
async for outcome in outcomes:
|
|
115
|
+
if isinstance(outcome, Done):
|
|
116
|
+
embedded, vector = outcome.value
|
|
117
|
+
clustered_items.append(embedded)
|
|
118
|
+
vectors.append(vector)
|
|
119
|
+
else:
|
|
120
|
+
diagnostics.warn(f"excluded: {describe_source(outcome.source)} ({outcome.reason})")
|
|
121
|
+
log.finish()
|
|
122
|
+
if not vectors:
|
|
123
|
+
return ExitCode.ALL_FAILED
|
|
124
|
+
|
|
125
|
+
# the threshold adapts to the embedder's geometry (measured: gemini's
|
|
126
|
+
# same-theme pairs sit near 0.7; a fixed bar can't serve every model)
|
|
127
|
+
clusters = leader_clusters(vectors, threshold=adaptive_threshold(vectors))
|
|
128
|
+
if request.k is not None and len(clusters) > request.k:
|
|
129
|
+
clusters = merge_to_k(vectors, clusters, k=request.k)
|
|
130
|
+
clusters = sorted(clusters, key=len, reverse=True)
|
|
131
|
+
|
|
132
|
+
labels = await _label_clusters(context, request, clusters, clustered_items)
|
|
133
|
+
|
|
134
|
+
writer = make_writer(
|
|
135
|
+
WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=None), stdout
|
|
136
|
+
)
|
|
137
|
+
if request.explode == "members":
|
|
138
|
+
member_label: dict[int, str] = {}
|
|
139
|
+
for members, label in zip(clusters, labels, strict=True):
|
|
140
|
+
for member in members:
|
|
141
|
+
member_label[member] = label
|
|
142
|
+
for position, item in enumerate(clustered_items):
|
|
143
|
+
record: dict[str, object]
|
|
144
|
+
if item.data is not None:
|
|
145
|
+
record = {key: value for key, value in item.data.items() if key != "vector"}
|
|
146
|
+
else:
|
|
147
|
+
record = {"text": item.raw}
|
|
148
|
+
record["cluster"] = member_label[position]
|
|
149
|
+
writer.write_record(record)
|
|
150
|
+
writer.flush()
|
|
151
|
+
return ExitCode.OK
|
|
152
|
+
|
|
153
|
+
total = len(clustered_items)
|
|
154
|
+
shown = clusters if request.top is None else clusters[: request.top]
|
|
155
|
+
for members, label in zip(shown, labels[: len(shown)], strict=True):
|
|
156
|
+
writer.write_record(
|
|
157
|
+
{
|
|
158
|
+
"cluster": label,
|
|
159
|
+
"size": len(members),
|
|
160
|
+
"share": round(len(members) / total, 2),
|
|
161
|
+
"examples": _examples(members, clustered_items, vectors),
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
folded = clusters[len(shown) :]
|
|
165
|
+
if folded:
|
|
166
|
+
size = sum(len(members) for members in folded)
|
|
167
|
+
writer.write_record(
|
|
168
|
+
{"cluster": "(other)", "size": size, "share": round(size / total, 2), "examples": []}
|
|
169
|
+
)
|
|
170
|
+
writer.flush()
|
|
171
|
+
return ExitCode.OK
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _label_clusters(
|
|
175
|
+
context: ClusterContext,
|
|
176
|
+
request: ClusterRequest,
|
|
177
|
+
clusters: list[list[int]],
|
|
178
|
+
items: list[Item],
|
|
179
|
+
) -> list[str]:
|
|
180
|
+
if request.model_flag is not None:
|
|
181
|
+
chat: ChatModel | None = await context.chat_model(request.model_flag)
|
|
182
|
+
else:
|
|
183
|
+
chat = await optional_chat(context)
|
|
184
|
+
if chat is None:
|
|
185
|
+
diagnostics.note(
|
|
186
|
+
"no chat model — clusters are unnamed (cluster 1, 2, …); "
|
|
187
|
+
"configure one for real labels: smartpipe config"
|
|
188
|
+
)
|
|
189
|
+
return [f"cluster {number}" for number in range(1, len(clusters) + 1)]
|
|
190
|
+
labels: list[str] = []
|
|
191
|
+
for number, members in enumerate(clusters, start=1):
|
|
192
|
+
texts = [items[member].text for member in members[:8]]
|
|
193
|
+
labels.append(await label_cluster(chat, texts, fallback=f"cluster {number}"))
|
|
194
|
+
return labels
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def label_cluster(chat: ChatModel, texts: list[str], *, fallback: str) -> str:
|
|
198
|
+
"""One temperature-0 label call for a group of similar texts (shared by
|
|
199
|
+
cluster and diff). Failures keep the fallback, disclosed."""
|
|
200
|
+
quotes = "\n".join(f"- {text[:200]}" for text in texts)
|
|
201
|
+
try:
|
|
202
|
+
reply = await chat.complete(
|
|
203
|
+
CompletionRequest(
|
|
204
|
+
system=_LABEL_SYSTEM,
|
|
205
|
+
user=f"Items in this cluster:\n{quotes}",
|
|
206
|
+
json_schema=_LABEL_SCHEMA,
|
|
207
|
+
max_tokens=64,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
verdict = validate_and_coerce(reply, _LABEL_SCHEMA)
|
|
211
|
+
return str(verdict["label"]).strip() or fallback
|
|
212
|
+
except ItemError as exc:
|
|
213
|
+
diagnostics.warn(f"{fallback} label failed ({exc}) — kept numbered")
|
|
214
|
+
return fallback
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _examples(members: list[int], items: list[Item], vectors: list[tuple[float, ...]]) -> list[str]:
|
|
218
|
+
centroid = mean_pool([vectors[member] for member in members])
|
|
219
|
+
nearest = sorted(members, key=lambda member: -cosine(vectors[member], centroid))
|
|
220
|
+
return [items[member].text[:160] for member in nearest[:_EXAMPLES]]
|