seam-code 0.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.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,425 @@
1
+ """Clustering orchestration — bridge between pure analysis and persistence.
2
+
3
+ Reads the symbol graph from the DB, runs community detection, labels each
4
+ cluster, and writes the results in a single transaction.
5
+
6
+ This module sits in the indexer layer and is called by cli/main.py after the
7
+ full file-indexing loop completes (never per-file, always whole-graph).
8
+
9
+ Import hierarchy (enforced):
10
+ indexer/cluster_index → analysis.clustering + analysis.cluster_naming + config
11
+ analysis modules → pure, no DB writes
12
+ cli → this module (not the other way)
13
+
14
+ Design decisions:
15
+ - Reads all symbols + edges from the connection in one pass.
16
+ - Calls detect_communities (pure) then label_cluster per community.
17
+ - Writes in ONE transaction: DELETE clusters, INSERT clusters, UPDATE symbols.
18
+ - NEVER raises: returns -1 on error (signals failure to CLI); any error logs
19
+ a warning and leaves cluster_id NULL. Returns ≥0 on success.
20
+ - Watcher does NOT call this — only seam init does.
21
+ - Deterministic cluster DB IDs: communities are sorted by their smallest
22
+ member name, then assigned IDs 1..N in that order. Same graph → same IDs.
23
+ - SEAM_CLUSTER_MIN_SIZE is enforced: communities smaller than min_size are
24
+ dropped; their symbols get cluster_id=NULL (unclustered).
25
+ - clusters.size is set from the actual DB row count after write-back, not
26
+ from the community node count (handles multi-row homonyms correctly).
27
+ - Write-back is batched: one executemany per cluster (not one UPDATE per node).
28
+ """
29
+
30
+ import logging
31
+ import sqlite3
32
+
33
+ import seam.config as config
34
+ from seam.analysis.cluster_naming import label_cluster
35
+ from seam.analysis.clustering import detect_communities
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _should_filter_edges(symbol_count: int) -> bool:
41
+ """Decide whether to apply the P2 confidence filter for this graph size.
42
+
43
+ The filter trims noisy AMBIGUOUS/inferred-call edges before community
44
+ detection. It is OPT-IN by graph size to preserve small-repo behaviour:
45
+ - "off" → never filter (byte-identical to pre-P2).
46
+ - numeric threshold → filter only when symbol_count > threshold.
47
+ A threshold of "0" forces the filter on for any non-empty graph
48
+ (used by tests and aggressive setups).
49
+
50
+ WHY size-gated: on a small/sparse graph the AMBIGUOUS edges are often the
51
+ only connective tissue — dropping them would shatter clusters. On a large
52
+ graph they are mostly homonym noise that wrongly merges unrelated modules.
53
+ """
54
+ raw = config.SEAM_CLUSTER_CONFIDENCE_FILTER
55
+ if raw == "off":
56
+ return False
57
+ try:
58
+ threshold = int(raw)
59
+ except (TypeError, ValueError):
60
+ # Misconfigured value → safest is to leave behaviour unchanged (no filter).
61
+ logger.warning(
62
+ "cluster_index: invalid SEAM_CLUSTER_CONFIDENCE_FILTER=%r — disabling filter",
63
+ raw,
64
+ )
65
+ return False
66
+ return symbol_count > threshold
67
+
68
+
69
+ # Max nodes sampled per cluster when computing cohesion (perf bound on hub clusters).
70
+ _COHESION_SAMPLE_CAP = 50
71
+
72
+
73
+ def _compute_cohesion(
74
+ member_names: list[str],
75
+ adjacency: dict[str, set[str]],
76
+ name_to_cluster: dict[str, int],
77
+ cluster_id: int,
78
+ ) -> float:
79
+ """Internal-edge ratio for one cluster (P2 cohesion score).
80
+
81
+ cohesion = (edges whose BOTH endpoints are in this cluster)
82
+ / (all edges touching a sampled member)
83
+
84
+ Range [0, 1]. 1.0 = perfectly self-contained (every edge stays inside the
85
+ cluster); near 0 = the cluster's members mostly link OUT to other clusters
86
+ (a weak/noisy community). Used as a small additive search-ranking bonus.
87
+
88
+ Perf: samples at most _COHESION_SAMPLE_CAP members so a hot hub cluster does
89
+ not make this O(huge). Sampling is deterministic (sorted, first N) so repeated
90
+ runs over the same graph produce the same value.
91
+
92
+ Returns 0.0 when the sampled members touch no edges (avoids div-by-zero).
93
+ """
94
+ sample = sorted(member_names)[:_COHESION_SAMPLE_CAP]
95
+ internal = 0
96
+ total = 0
97
+ for name in sample:
98
+ for neighbor in adjacency.get(name, ()): # undirected neighbours
99
+ total += 1
100
+ if name_to_cluster.get(neighbor) == cluster_id:
101
+ internal += 1
102
+ if total == 0:
103
+ return 0.0
104
+ return internal / total
105
+
106
+
107
+ def index_clusters(
108
+ conn: sqlite3.Connection,
109
+ naming_mode: str = "deterministic",
110
+ llm_api_key: str | None = None,
111
+ llm_model: str | None = None,
112
+ min_size: int = 1,
113
+ ) -> int:
114
+ """Detect and persist graph communities for all indexed symbols.
115
+
116
+ Reads the full symbol+edge graph, partitions into communities, assigns
117
+ deterministic labels, and stores everything in the clusters table and
118
+ symbols.cluster_id column.
119
+
120
+ Called by `seam init` after the indexing loop, NEVER by the watcher.
121
+
122
+ Args:
123
+ conn: Open SQLite connection (must have write access).
124
+ naming_mode: "deterministic" (default) or "llm".
125
+ llm_api_key: API key for LLM naming (required if naming_mode="llm").
126
+ llm_model: LLM model name (optional; uses default if None).
127
+ min_size: Minimum community size to persist as a cluster.
128
+ Communities smaller than this get cluster_id=NULL.
129
+ Default 1 (all communities; effectively no filter).
130
+
131
+ Returns:
132
+ Number of clusters created (≥0). Returns -1 on error (never raises).
133
+
134
+ WHY: Returning -1 (not 0) on error lets the CLI distinguish "zero clusters
135
+ because the graph has no connected edges" from "clustering failed."
136
+ Never raises so `seam init` can't be aborted by a clustering bug.
137
+ """
138
+ try:
139
+ return _index_clusters_impl(conn, naming_mode, llm_api_key, llm_model, min_size)
140
+ except Exception as exc:
141
+ logger.warning(
142
+ "cluster_index: failed to compute clusters (%s: %s) — "
143
+ "all symbols will have cluster_id=NULL; run 'seam init' again to retry",
144
+ type(exc).__name__,
145
+ exc,
146
+ )
147
+ return -1
148
+
149
+
150
+ def _index_clusters_impl(
151
+ conn: sqlite3.Connection,
152
+ naming_mode: str,
153
+ llm_api_key: str | None,
154
+ llm_model: str | None,
155
+ min_size: int,
156
+ ) -> int:
157
+ """Inner implementation. May raise — outer function is the guard.
158
+
159
+ WHY separate function: the outer wrapper catches ALL exceptions and converts
160
+ them to -1. Having a clean inner function makes the logic easier to reason
161
+ about and test (tests can call the inner function directly if needed).
162
+ """
163
+ # ── Step 0: Always clear stale cluster state FIRST ────────────────────────
164
+ # This must happen before ANY early-return so a previous successful run
165
+ # is never left as a phantom when the current run finds nothing to cluster.
166
+ # WHY: If we returned early without clearing, old cluster rows would persist
167
+ # even though the symbol table is now empty — "ghost" clusters in list output.
168
+ with conn:
169
+ conn.execute("DELETE FROM clusters")
170
+ conn.execute("UPDATE symbols SET cluster_id = NULL")
171
+
172
+ # ── Step 1: Read all symbols ──────────────────────────────────────────────
173
+ # We need: name (for the graph), file (for labeling), degree (for label heuristic)
174
+ symbol_rows = conn.execute(
175
+ """
176
+ SELECT s.name, f.path AS file
177
+ FROM symbols s
178
+ JOIN files f ON f.id = s.file_id
179
+ ORDER BY s.name
180
+ """
181
+ ).fetchall()
182
+
183
+ if not symbol_rows:
184
+ logger.debug("cluster_index: no symbols in index, skipping clustering")
185
+ return 0
186
+
187
+ # Map: symbol name → file path (for labeling)
188
+ # WHY: Multiple symbols can share a name (ambiguous); use first file path seen.
189
+ name_to_file: dict[str, str] = {}
190
+ for row in symbol_rows:
191
+ name = row["name"]
192
+ if name not in name_to_file:
193
+ name_to_file[name] = row["file"]
194
+
195
+ all_nodes = list(name_to_file.keys())
196
+
197
+ # ── Step 2: Read all edges as undirected pairs ────────────────────────────
198
+ # Read confidence + kind too so the P2 confidence filter (Step 2b) can drop
199
+ # noisy edges before community detection on large graphs. The unfiltered set
200
+ # is always kept for the cohesion computation (Step 8b) so cohesion reflects
201
+ # the REAL connectivity, not the filtered view.
202
+ #
203
+ # Exclude SYNTHESIZED edges (synthesized_by IS NOT NULL): they are a heuristic
204
+ # over-approximation produced by the post-pass that runs AFTER clustering, and
205
+ # they survive across runs (stored under the ':synthesis:' file row). Feeding
206
+ # them into Louvain would re-cluster the codebase against last run's guessed
207
+ # dispatch edges — merging unrelated modules on every re-cluster. The synthesis
208
+ # engine already excludes them from its own input for the same anti-feedback
209
+ # reason; clustering must too. Guarded for pre-v12 indexes lacking the column.
210
+ _edge_cols = {r["name"] for r in conn.execute("PRAGMA table_info(edges)").fetchall()}
211
+ _synth_filter = "WHERE synthesized_by IS NULL" if "synthesized_by" in _edge_cols else ""
212
+ edge_rows = conn.execute(
213
+ f"SELECT DISTINCT source_name, target_name, kind, confidence FROM edges {_synth_filter}"
214
+ ).fetchall()
215
+ all_edges = [(row["source_name"], row["target_name"]) for row in edge_rows]
216
+
217
+ # ── Step 2b: P2 confidence filter — large graphs only ─────────────────────
218
+ # Pass only high-trust edges to Louvain when the graph is big enough that
219
+ # AMBIGUOUS homonym noise would wrongly merge unrelated modules. High-trust =
220
+ # EXTRACTED (resolved to one symbol) OR import-kind INFERRED (real dependency,
221
+ # just not name-unique). Everything else (AMBIGUOUS, inferred CALL edges) is
222
+ # dropped. Small repos pass the full set (see _should_filter_edges).
223
+ if _should_filter_edges(len(all_nodes)):
224
+ clustering_edges = [
225
+ (row["source_name"], row["target_name"])
226
+ for row in edge_rows
227
+ if row["confidence"] == "EXTRACTED"
228
+ or (row["confidence"] == "INFERRED" and row["kind"] == "import")
229
+ ]
230
+ logger.debug(
231
+ "cluster_index: confidence filter active — %d/%d edges passed to Louvain",
232
+ len(clustering_edges),
233
+ len(all_edges),
234
+ )
235
+ else:
236
+ clustering_edges = all_edges
237
+
238
+ # ── Step 3: Detect communities (pure function) ────────────────────────────
239
+ community_map: dict[str, int] = detect_communities(all_nodes, clustering_edges)
240
+
241
+ if not community_map:
242
+ logger.debug("cluster_index: detect_communities returned empty map")
243
+ return 0
244
+
245
+ # ── Step 4: Group nodes by cluster ID and apply min_size filter ───────────
246
+ # cluster_id (int from detect_communities) → list of node names
247
+ raw_cluster_members: dict[int, list[str]] = {}
248
+ for node, cid in community_map.items():
249
+ raw_cluster_members.setdefault(cid, []).append(node)
250
+
251
+ # Keep only communities with at least min_size distinct graph nodes.
252
+ # WHY: min_size=2 (the default in config) kills pure singletons so `seam clusters`
253
+ # shows functional areas, not every unconnected symbol in its own row.
254
+ # Nodes in dropped communities get cluster_id=NULL (already cleared in Step 0).
255
+ cluster_members: dict[int, list[str]] = {
256
+ cid: members
257
+ for cid, members in raw_cluster_members.items()
258
+ if len(members) >= min_size
259
+ }
260
+
261
+ if not cluster_members:
262
+ logger.debug(
263
+ "cluster_index: all communities are below min_size=%d — no clusters persisted",
264
+ min_size,
265
+ )
266
+ return 0
267
+
268
+ # ── Step 5: Compute degree per node (for labeling heuristic) ─────────────
269
+ # Degree = number of edges touching this symbol (undirected)
270
+ node_degree: dict[str, int] = {n: 0 for n in all_nodes}
271
+ for src, tgt in all_edges:
272
+ if src in node_degree:
273
+ node_degree[src] += 1
274
+ if tgt in node_degree:
275
+ node_degree[tgt] += 1
276
+
277
+ # ── Step 6: Order communities deterministically for stable DB IDs ─────────
278
+ # WHY (issue #1): AUTOINCREMENT IDs climb on every DELETE+INSERT, so IDs
279
+ # drift across re-runs even when the graph is unchanged. Instead we INSERT
280
+ # with explicit IDs 1..N. The ordering key is the lexicographically smallest
281
+ # member name of each community — the same key used internally by detect_communities
282
+ # to assign stable algorithm IDs, so this ordering is doubly stable.
283
+ ordered_algo_ids: list[int] = sorted(
284
+ cluster_members.keys(),
285
+ key=lambda cid: min(cluster_members[cid]), # min member name → deterministic
286
+ )
287
+
288
+ # ── Step 7: Compute labels for each persisted community ───────────────────
289
+ cluster_labels: dict[int, tuple[str, str]] = {}
290
+ for algo_cid in ordered_algo_ids:
291
+ members_info = [
292
+ {
293
+ "name": name,
294
+ "file": name_to_file.get(name, ""),
295
+ "degree": node_degree.get(name, 0),
296
+ }
297
+ for name in sorted(cluster_members[algo_cid]) # sorted for determinism
298
+ ]
299
+ label, naming_source = label_cluster(
300
+ members_info,
301
+ naming_mode=naming_mode,
302
+ api_key=llm_api_key,
303
+ model=llm_model,
304
+ )
305
+ cluster_labels[algo_cid] = (label, naming_source)
306
+
307
+ # ── Step 7b: Compute cohesion per cluster (P2 internal-edge ratio) ─────────
308
+ # cohesion(cluster) = internal edges / all edges touching sampled members.
309
+ # Built from the FULL (unfiltered) edge graph so the score reflects real
310
+ # connectivity, not the filtered Louvain view. Stable DB id = position in
311
+ # ordered_algo_ids (1-based), matching the INSERT loop below.
312
+ adjacency: dict[str, set[str]] = {n: set() for n in all_nodes}
313
+ for src, tgt in all_edges:
314
+ if src in adjacency and tgt in adjacency and src != tgt:
315
+ adjacency[src].add(tgt)
316
+ adjacency[tgt].add(src)
317
+ # name → stable DB cluster id (only for persisted members).
318
+ name_to_cluster: dict[str, int] = {}
319
+ for db_id, algo_cid in enumerate(ordered_algo_ids, start=1):
320
+ for name in cluster_members[algo_cid]:
321
+ name_to_cluster[name] = db_id
322
+ cohesion_by_db_id: dict[int, float] = {}
323
+ for db_id, algo_cid in enumerate(ordered_algo_ids, start=1):
324
+ cohesion_by_db_id[db_id] = _compute_cohesion(
325
+ cluster_members[algo_cid], adjacency, name_to_cluster, db_id
326
+ )
327
+
328
+ # ── Step 8: Persist in one transaction with explicit stable IDs ───────────
329
+ # WHY single transaction: if any INSERT/UPDATE fails the rollback leaves
330
+ # the DB in a consistent state (all cluster_id=NULL from Step 0 clear).
331
+ with conn:
332
+ # Map: algorithm cluster ID → stable DB cluster ID (1-based, ordered by min member)
333
+ algo_to_db_id: dict[int, int] = {}
334
+ for db_id, algo_cid in enumerate(ordered_algo_ids, start=1):
335
+ label, naming_source = cluster_labels[algo_cid]
336
+ # Insert with explicit ID so IDs are identical across re-runs.
337
+ # Size is a placeholder (0) — updated to actual count after write-back (issue #3).
338
+ # cohesion (P2) is stored at insert time from the precomputed map.
339
+ conn.execute(
340
+ "INSERT INTO clusters (id, label, size, naming_source, cohesion)"
341
+ " VALUES (?, ?, 0, ?, ?)",
342
+ (db_id, label, naming_source, cohesion_by_db_id[db_id]),
343
+ )
344
+ algo_to_db_id[algo_cid] = db_id
345
+
346
+ # Batch write-back: one executemany per cluster instead of one UPDATE per node.
347
+ # WHY (issue #9): a 100k-symbol repo would run 100k individual UPDATEs without this.
348
+ # Each cluster's members share the same db_id, so we group and write at once.
349
+ for algo_cid, db_id in algo_to_db_id.items():
350
+ member_names = cluster_members[algo_cid]
351
+ # executemany issues one call per row but SQLite batches them in one round-trip
352
+ conn.executemany(
353
+ "UPDATE symbols SET cluster_id = ? WHERE name = ?",
354
+ [(db_id, name) for name in member_names],
355
+ )
356
+
357
+ # Fix cluster.size to actual DB member count (issue #3).
358
+ # WHY: UPDATE WHERE name=? stamps EVERY row with that name, so if the same
359
+ # symbol name appears in two files, both rows get the cluster_id. The node
360
+ # count from community detection counts unique NAMES, not rows. We must
361
+ # re-count from the DB to get the true member row count per cluster.
362
+ for db_id in algo_to_db_id.values():
363
+ actual_size = conn.execute(
364
+ "SELECT COUNT(*) FROM symbols WHERE cluster_id = ?", (db_id,)
365
+ ).fetchone()[0]
366
+ conn.execute(
367
+ "UPDATE clusters SET size = ? WHERE id = ?", (actual_size, db_id)
368
+ )
369
+
370
+ n_clusters = len(cluster_members)
371
+
372
+ # ── Step 9: Surface LLM-naming fallback stats (issue #8) ─────────────────
373
+ # When LLM naming was requested, report how many clusters fell back to deterministic.
374
+ # This is a read-only post-write query; any error is silently ignored.
375
+ llm_summary: str | None = None
376
+ if naming_mode == "llm":
377
+ try:
378
+ source_rows = conn.execute(
379
+ "SELECT naming_source, COUNT(*) AS n FROM clusters GROUP BY naming_source"
380
+ ).fetchall()
381
+ source_counts = {r["naming_source"]: r["n"] for r in source_rows}
382
+ llm_count = source_counts.get("llm", 0)
383
+ det_count = source_counts.get("deterministic", 0)
384
+ fallback = det_count
385
+ if fallback > 0:
386
+ llm_summary = f"{llm_count}/{n_clusters} llm-named, {fallback} fell back to deterministic"
387
+ else:
388
+ llm_summary = f"{llm_count}/{n_clusters} llm-named"
389
+ except Exception:
390
+ pass # non-critical summary; don't let this break the return value
391
+
392
+ logger.info(
393
+ "cluster_index: %d cluster(s) computed (%s naming)%s",
394
+ n_clusters,
395
+ naming_mode,
396
+ f" [{llm_summary}]" if llm_summary else "",
397
+ )
398
+ return n_clusters
399
+
400
+
401
+ def get_llm_naming_summary(conn: sqlite3.Connection) -> str | None:
402
+ """Return a human-readable LLM naming summary string, or None if not applicable.
403
+
404
+ Reads the clusters table and returns a string like
405
+ "naming: llm requested, 3/10 fell back to deterministic" for use by the CLI.
406
+
407
+ Returns None when the clusters table is empty or inaccessible.
408
+ WHY: Exposed as a separate callable so cli/main.py can print the summary after
409
+ index_clusters() without duplicating the query logic.
410
+ """
411
+ try:
412
+ rows = conn.execute(
413
+ "SELECT naming_source, COUNT(*) AS n FROM clusters GROUP BY naming_source"
414
+ ).fetchall()
415
+ if not rows:
416
+ return None
417
+ source_counts = {r["naming_source"]: r["n"] for r in rows}
418
+ total = sum(source_counts.values())
419
+ llm_n = source_counts.get("llm", 0)
420
+ det_n = source_counts.get("deterministic", 0)
421
+ if det_n > 0:
422
+ return f"naming: llm requested, {det_n}/{total} fell back to deterministic"
423
+ return f"naming: llm ({llm_n}/{total} named by LLM)"
424
+ except Exception:
425
+ return None