Skip to content

Commit 4543b2f

Browse files
thewrzclaude
andauthored
feat(setbuilder): pool import resolves master tracks store + pre-build coverage gate (#554)
* feat(setbuilder): pool import resolves master tracks store + pre-build coverage gate Cut the WrzDJSet pool import over to the global tracks store (#540/#541) so each imported recording is enriched once and reused across sets and events. Every import flow now runs hydrate_candidates_from_store BEFORE import_candidates: - trusted+complete store row -> hydrate candidate gaps with ZERO provider calls - store miss but candidate carries fields (Beatport/Tidal playlists) -> upsert to POPULATE the store from the candidate (ZERO provider calls) - genuine gaps + a connected DJ -> run the provider cascade once (enrich_track), write back to the store, then hydrate Commit discipline mirrors the request-side _safe_upsert_track: REST flows commit the store write durably (never poisons the import); agent import tools pass commit=False so the store write rides the single agent-turn transaction. The cache short-circuit gates on bpm/key/genre/duration (the provider-fillable fields); energy is excluded by design since it comes only from Soundcharts/ Lexicon (dark), so a provider-enriched row still serves as a cache hit instead of re-hitting providers forever. Adds a pure coverage check over the five required pool->builder fields (bpm/key/genre/duration/energy) with per-field missing counts + a ready signal. The deterministic build endpoint and the agent autobuild path attach it to their response as a SOFT, overridable signal for the build-confirmation dialog (#538) — the build is never hard-blocked on it. Extends analyze_pool_gaps to also report energy + genre coverage, read from the resolved pool rows (not the dead pool energy column). Regenerated openapi.json + dashboard api types. Closes #542 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): persist pool source before hydration loop (#554 review) Codex review P2: every REST import does get_or_create_source (flush, uncommitted) -> hydrate_candidates_from_store(commit=True) -> import_candidates. If a candidate raised before any _safe_upsert reached its commit-first db.commit() (e.g. enrich_track throws on the first candidate), the per-candidate db.rollback() in the commit=True recovery branch discarded the still-uncommitted source row, and import_candidates then inserted pool tracks against a stale source.id. Commit once before the candidate loop on the commit=True path so the flushed source is durable; per-candidate rollbacks can then only discard in-flight store-write state, never the source. The commit=False agent path owns its single transaction and is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): thread candidate ISRC into enrich write-back (#554 review) Codex review P2: _write_candidate_to_store threaded the validated ISRC into the store write, but _enrich_and_writeback hard-coded isrc=None. A Spotify/public-URL candidate (carries an ISRC but no bpm/key/genre) takes the enrich path, so its store row was written ISRC-less -> a later by-ISRC lookup missed and re-ran the providers, defeating the dedupe win. Thread the valid_isrc computed in _hydrate_one through to the enrich write-back so the row is keyed by ISRC (consistent with #552 storing the submitted ISRC). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): per-field trusted hydration from the store (#554 review) Codex review P2: a store row was ignored entirely unless bpm+key+genre+duration were ALL present and authoritative, so a partially-cached row contributed nothing — worst for a DJ with no connected providers, who could not enrich the gap and so got none of the trusted fields the store already held. Hydrate PER-FIELD instead: fill each missing candidate field whose row value is present AND from an authoritative (50+ precedence) source, THEN recompute the remaining gaps to decide populate-vs-enrich-vs-leave. Two guards preserve the existing semantics: - Values hydrated FROM the row are tracked and NOT written back to the store (no churn, no legacy provenance downgrade of a beatport/tidal-sourced field). - Energy may be hydrated from an authoritative row but stays excluded from the provider-enrich gate (still only Soundcharts/Lexicon, dark per #543/#544). Replaces the all-or-nothing _row_trusted_complete / _CACHE_GATE_FIELDS with _hydrate_authoritative_fields + _has_provider_gap; the post-enrich tail fills the candidate from its own freshly-resolved values via _fill_missing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): carry source_service so manual provider picks store authoritative (#554 review) Codex re-review P2: a manual Beatport/Tidal search pick (DJ search -> add, the most common import path) was stored as legacy precedence, defeating the cache. Root cause across the stack: the unified SearchResult schema exposes only spotify_id, so ImportModal sends source_service="beatport"/"tidal" but source_track_id=None. candidate_from_manual therefore left track_id=None, and _candidate_source fell back to the (absent) track_id prefix -> "legacy" (precedence 30). The provider-measured bpm/key/genre was written sub-authoritative, so _hydrate_authoritative_fields (>=50 gate) never reused it and every later import of that recording re-queried providers. Carry the provider explicitly (backend; no FE change available): - Add source_service to PoolCandidate (preserved across dataclasses.replace). - candidate_from_manual sets it for beatport/tidal; spotify (not a bpm/key authority) and hand-typed "manual" intentionally stay None -> legacy. - _candidate_source prefers an explicit beatport/tidal source_service over the track_id prefix; the playlist builders still match via their beatport:/tidal: prefix; everything else stays legacy. Dropped the dead "manual" prefix branch (it never produced a manual: track_id and would have wrongly claimed precedence 100 for typed data). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): never trust client-asserted provenance for manual picks (#554 P1) Codex final-pass P1 SECURITY: FIX 4 (598061d) trusted PoolImportManualIn's client-supplied source_service as authoritative provider provenance. Any authenticated DJ could POST fabricated bpm/key/genre with source_service="beatport" and _candidate_source would write them as AUTHORITATIVE into the GLOBAL, multi-tenant tracks store that other DJs hydrate from (and equal-precedence writes could overwrite real provider fields) — cache poisoning. The backend cannot verify client-asserted provenance (there is no server-side provider id; that is the same SearchResult gap that motivated FIX 4), so manual picks MUST store as legacy. Revert the trust, keep the unrelated cleanup: - Remove the source_service field from PoolCandidate (only FIX 4 read it). - candidate_from_manual: drop the store_source derivation + source_service= arg; a manual pick with no server-trusted track_id stays track_id=None -> legacy. The source_service PARAMETER stays (it still mints beatport:<id>/tidal:<id> track_ids for picks that carry a real, server-side source_track_id). - _candidate_source: drop the source_service preference; authority comes only from a server-minted beatport:/tidal: track_id prefix. Kept FIX 4's removal of the dead "manual" prefix branch (that cleanup was correct). A legacy row self-heals: the next connected-DJ import runs enrich_track SERVER-SIDE and upgrades it to real beatport/tidal precedence, so the precedence guard cleanly overrides it. The one-time re-enrichment cost is consciously accepted — security over the dedupe optimization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): ISRC-conflict guard on signature-fallback hydration (#554 P2) Codex final-pass P2: get_track is ISRC-first then signature-fallback, so a candidate with a valid ISRC NOT in the store whose normalized artist/title signature matches a DIFFERENT recording's row (different ISRC) gets that row back, and _hydrate_one copied its bpm/key/genre/duration onto this candidate — the wrong recording — suppressing enrichment because the gaps looked filled. upsert_track already refuses the ISRC-mismatched WRITE (#552), but the read-side hydration had already contaminated the candidate. Guard hydration on ISRC compatibility (mirrors the request-side sync/enrichment_pipeline check): hydrate from the row only when isrc is None or row.isrc is None or row.isrc == isrc. On a genuine conflict, skip _hydrate_authoritative_fields and let enrichment resolve the candidate's own recording. The normal signature-hit case (row has no ISRC) is unaffected and still hydrates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): never mint a client-forgeable provider prefix for manual picks (#554 P1) Codex final pass: the P1 revert (9a0f77e) was incomplete. candidate_from_manual still minted track_id = f"{source_service}:{source_track_id}" from CLIENT-supplied source_service + source_track_id (both from PoolImportManualIn). A crafted POST {source_service:"beatport", source_track_id:"x", bpm:200, ...} produced track_id="beatport:x" → _candidate_source returns "beatport" (authoritative) → _write_candidate_to_store poisons the shared multi-tenant store. The docstring's "server-trusted" claim was false — it was client input. candidate_from_manual now mints ONLY a non-authoritative spotify:<id> reference (Spotify resolves to legacy regardless, and is the one provider the FE sends an id for); beatport/tidal/manual leave track_id=None → legacy. Authoritative beatport:/tidal: prefixes come ONLY from the server-side playlist builders (candidates_from_beatport/candidates_from_tidal) via the DJ's OAuth'd fetch. _candidate_source is unchanged (no manual input can now produce a provider prefix). Updated test_manual_import (it asserted the vulnerable tidal:555 minting) to assert track_id is None for a client tidal pick, plus a spotify-reference-retained case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setbuilder): count duration_sec in the store-write gate (#554 P2) Codex final pass: _candidate_has_writable_fields gated the store write on bpm/key/genre only, but _write_candidate_to_store DOES persist duration_sec. So a candidate whose only contributed field is duration_sec (Spotify/public-URL imports carry duration but no bpm/key/genre), or one that adds duration after bpm/key/genre were hydrated-from-row (all in exclude), returned False -> the store write was skipped -> duration (a required pool->builder contract field) never cached, and later imports couldn't hydrate it. Add duration_sec to the gate under the same exclude rule _write_candidate_to_store already applies; no other change needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8bee480 commit 4543b2f

17 files changed

Lines changed: 1408 additions & 18 deletions

dashboard/lib/api-types.generated.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2715,6 +2715,10 @@ export interface paths {
27152715
/**
27162716
* Build Set
27172717
* @description Run deterministic pass 1 after explicit user confirmation.
2718+
*
2719+
* Coverage of the five required pool→builder fields is computed and returned so
2720+
* the build-confirmation dialog can show data completeness and a SOFT,
2721+
* overridable warning (#542/#538) — the build itself is never blocked on it.
27182722
*/
27192723
post: operations["build_set_api_setbuilder_sets__set_id__build_post"];
27202724
delete?: never;
@@ -4249,6 +4253,7 @@ export interface components {
42494253
* @description Result of the deterministic pass.
42504254
*/
42514255
BuildSetResponse: {
4256+
coverage: components["schemas"]["PoolCoverageOut"];
42524257
/** Iterations */
42534258
iterations: number;
42544259
/** Slot Count */
@@ -5786,6 +5791,27 @@ export interface components {
57865791
/** Source */
57875792
source: string;
57885793
};
5794+
/**
5795+
* PoolCoverageOut
5796+
* @description Pre-build coverage of the five required pool→builder contract fields (#542).
5797+
*
5798+
* A SOFT, overridable signal surfaced in the build-confirmation dialog (#538):
5799+
* ``missing`` is the per-field count of pool tracks lacking each field,
5800+
* ``fully_covered_count`` how many carry all five, and ``ready`` whether the
5801+
* pool clears the readiness threshold. The build is never hard-blocked on this.
5802+
*/
5803+
PoolCoverageOut: {
5804+
/** Fully Covered Count */
5805+
fully_covered_count: number;
5806+
/** Missing */
5807+
missing: {
5808+
[key: string]: number;
5809+
};
5810+
/** Pool Size */
5811+
pool_size: number;
5812+
/** Ready */
5813+
ready: boolean;
5814+
};
57895815
/**
57905816
* PoolImportEventIn
57915817
* @description Body for importing a WrzDJ event's requests.

server/app/api/setbuilder.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
PlaybackReportSummary,
5555
PlaybackSlotOutcomeOut,
5656
PlayHistoryFeedbackOut,
57+
PoolCoverageOut,
5758
PoolImportEventIn,
5859
PoolImportManualIn,
5960
PoolImportPlaylistIn,
@@ -113,6 +114,9 @@
113114
vibe_enrichment,
114115
vibe_resolver,
115116
)
117+
from app.services.setbuilder import (
118+
coverage as pool_coverage_service,
119+
)
116120
from app.services.setbuilder.playlist_url import InvalidPlaylistUrl, parse_public_playlist_url
117121

118122
router = APIRouter()
@@ -665,17 +669,23 @@ def build_set(
665669
db: Session = Depends(get_db),
666670
current_user: User = Depends(get_current_active_user),
667671
) -> BuildSetResponse:
668-
"""Run deterministic pass 1 after explicit user confirmation."""
672+
"""Run deterministic pass 1 after explicit user confirmation.
673+
674+
Coverage of the five required pool→builder fields is computed and returned so
675+
the build-confirmation dialog can show data completeness and a SOFT,
676+
overridable warning (#542/#538) — the build itself is never blocked on it."""
669677
set_obj = _get_owned_or_404(db, set_id, current_user)
670678
if not payload.confirmed:
671679
raise HTTPException(status_code=400, detail="Build requires explicit confirmation")
680+
coverage = pool_coverage_service.coverage_for_set(db, set_obj.id)
672681
result = pass1_deterministic.build_set(db, set_obj)
673682
db.expire(set_obj, ["slots"])
674683
return BuildSetResponse(
675684
slot_count=result.slot_count,
676685
iterations=result.iterations,
677686
slots=_slots_out(db, set_obj),
678687
transition_scores=_transition_scores_out(result.transition_scores),
688+
coverage=PoolCoverageOut(**coverage),
679689
)
680690

681691

@@ -1079,6 +1089,7 @@ def import_pool_event(
10791089
label=event.name,
10801090
meta="WrzDJ event requests",
10811091
)
1092+
candidates = pool.hydrate_candidates_from_store(db, candidates, user=current_user)
10821093
added, deduped = pool.import_candidates(db, set_obj, source, candidates)
10831094
return _import_result(db, set_obj.id, source, added, deduped)
10841095

@@ -1108,6 +1119,7 @@ def import_pool_tidal(
11081119
label=payload.label or "Tidal playlist",
11091120
meta="Tidal playlist",
11101121
)
1122+
candidates = pool.hydrate_candidates_from_store(db, candidates, user=current_user)
11111123
added, deduped = pool.import_candidates(db, set_obj, source, candidates)
11121124
return _import_result(db, set_obj.id, source, added, deduped)
11131125

@@ -1133,6 +1145,7 @@ def import_pool_beatport(
11331145
label=payload.label or "Beatport playlist",
11341146
meta="Beatport playlist",
11351147
)
1148+
candidates = pool.hydrate_candidates_from_store(db, candidates, user=current_user)
11361149
added, deduped = pool.import_candidates(db, set_obj, source, candidates)
11371150
return _import_result(db, set_obj.id, source, added, deduped)
11381151

@@ -1192,6 +1205,7 @@ def import_pool_url(
11921205
label=name,
11931206
meta=f"Public {parsed.provider} playlist",
11941207
)
1208+
candidates = pool.hydrate_candidates_from_store(db, candidates, user=current_user)
11951209
added, deduped = pool.import_candidates(db, set_obj, source, candidates)
11961210
return _import_result(db, set_obj.id, source, added, deduped)
11971211

@@ -1223,7 +1237,8 @@ def import_pool_manual(
12231237
source = pool.get_or_create_source(
12241238
db, set_obj, kind="manual", external_ref=None, label="Manual", meta="Single-track search"
12251239
)
1226-
added, deduped = pool.import_candidates(db, set_obj, source, [candidate])
1240+
candidates = pool.hydrate_candidates_from_store(db, [candidate], user=current_user)
1241+
added, deduped = pool.import_candidates(db, set_obj, source, candidates)
12271242
return _import_result(db, set_obj.id, source, added, deduped)
12281243

12291244

server/app/schemas/setbuilder.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,13 +443,29 @@ class TransitionScoreOut(BaseModel):
443443
warnings: list[str]
444444

445445

446+
class PoolCoverageOut(BaseModel):
447+
"""Pre-build coverage of the five required pool→builder contract fields (#542).
448+
449+
A SOFT, overridable signal surfaced in the build-confirmation dialog (#538):
450+
``missing`` is the per-field count of pool tracks lacking each field,
451+
``fully_covered_count`` how many carry all five, and ``ready`` whether the
452+
pool clears the readiness threshold. The build is never hard-blocked on this.
453+
"""
454+
455+
pool_size: int
456+
fully_covered_count: int
457+
ready: bool
458+
missing: dict[str, int]
459+
460+
446461
class BuildSetResponse(BaseModel):
447462
"""Result of the deterministic pass."""
448463

449464
slot_count: int
450465
iterations: int
451466
slots: list[SlotOut]
452467
transition_scores: list[TransitionScoreOut]
468+
coverage: PoolCoverageOut
453469

454470

455471
class SlotOrderRequest(BaseModel):

server/app/services/setbuilder/agent_display.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,13 @@ def _tool_display_summary(
136136
if name == "analyze_pool_gaps":
137137
missing = result.get("missing_camelot_keys") or []
138138
sparse = result.get("sparse_bands") or []
139+
missing_genre = int(result.get("missing_genre_count") or 0)
140+
missing_energy = int(result.get("missing_energy_count") or 0)
139141
return (
140142
f"Analyzed pool gaps over {int(result.get('pool_size') or 0)} tracks: "
141143
f"{len(missing)} missing Camelot key{'s' if len(missing) != 1 else ''}, "
142-
f"{len(sparse)} sparse BPM band{'s' if len(sparse) != 1 else ''}."
144+
f"{len(sparse)} sparse BPM band{'s' if len(sparse) != 1 else ''}, "
145+
f"{missing_genre} missing genre, {missing_energy} missing energy."
143146
)
144147
if name == "critique_set":
145148
grade = result.get("overall_grade")
@@ -161,10 +164,16 @@ def _tool_display_summary(
161164
if name == "autobuild":
162165
slots = int(result.get("slot_count") or 0)
163166
iterations = int(result.get("iterations") or 0)
164-
return (
167+
summary = (
165168
f"Rebuilt the set: {slots} slot{'s' if slots != 1 else ''}, "
166169
f"{iterations} refinement pass{'es' if iterations != 1 else ''}."
167170
)
171+
coverage = result.get("coverage") or {}
172+
pool_size = int(coverage.get("pool_size") or 0)
173+
fully = int(coverage.get("fully_covered_count") or 0)
174+
if pool_size and fully < pool_size:
175+
summary += f" {fully}/{pool_size} pool tracks fully enriched."
176+
return summary
168177
if name == "fill_to_duration":
169178
added = int(result.get("inserted_count") or 0)
170179
now_min = int(result.get("estimated_total_sec") or 0) // 60

server/app/services/setbuilder/agent_tool_specs.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,10 @@ def _agent_tools() -> list[ToolSpec]:
186186
),
187187
ToolSpec(
188188
name="analyze_pool_gaps",
189-
description=("Report pool coverage holes: missing Camelot keys and sparse BPM bands."),
189+
description=(
190+
"Report pool coverage holes: missing Camelot keys, sparse BPM bands, "
191+
"and how many tracks are missing genre or energy."
192+
),
190193
input_schema={"type": "object", "properties": {}},
191194
),
192195
ToolSpec(

server/app/services/setbuilder/agent_tools_imports.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def _tool_import_from_event(
104104
label=event.name,
105105
meta="WrzDJ event requests",
106106
)
107+
candidates = pool.hydrate_candidates_from_store(db, candidates, user=owner, commit=False)
107108
added, deduped = pool.import_candidates(db, set_obj, source, candidates, commit=False)
108109
return _import_summary(source, added, deduped), set()
109110

@@ -151,6 +152,7 @@ def _connected_playlist_import(
151152
label=playlist.name,
152153
meta=f"{kind.capitalize()} playlist",
153154
)
155+
candidates = pool.hydrate_candidates_from_store(db, candidates, user=owner, commit=False)
154156
added, deduped = pool.import_candidates(db, set_obj, source, candidates, commit=False)
155157
return _import_summary(source, added, deduped), set()
156158

@@ -219,5 +221,6 @@ def _tool_import_from_url(
219221
label=name,
220222
meta=f"Public {parsed.provider} playlist",
221223
)
224+
candidates = pool.hydrate_candidates_from_store(db, candidates, user=owner, commit=False)
222225
added, deduped = pool.import_candidates(db, set_obj, source, candidates, commit=False)
223226
return _import_summary(source, added, deduped), set()

server/app/services/setbuilder/agent_tools_sensing.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,26 +255,40 @@ def _energy_profile(values: list[float | None]) -> dict[str, Any]:
255255
def _tool_analyze_pool_gaps(
256256
db: Session, set_obj: Set, payload: dict[str, Any]
257257
) -> tuple[dict[str, Any], set[int]]:
258-
"""Read-only coverage report over the set's pool (missing keys + BPM bands)."""
258+
"""Read-only coverage report over the set's pool: missing Camelot keys, BPM
259+
bands, and genre + energy coverage (#542).
260+
261+
Genre + energy are read straight from the resolved pool rows (which #542's
262+
store hydration now fills), not from the previously-dead pool energy column —
263+
consistent with the build-gate ``coverage.pool_coverage`` field set."""
259264
del payload
260-
metas = [_pass1_track_meta(t) for t in _pool_tracks(db, set_obj.id)]
265+
pool = _pool_tracks(db, set_obj.id)
266+
metas = [_pass1_track_meta(t) for t in pool]
261267
camelot_keys = [str(pos) for pos in (parse_key(m.key) for m in metas) if pos is not None]
262268
bpms = [float(m.bpm) for m in metas if m.bpm is not None]
269+
genre_count = sum(1 for t in pool if t.genre)
270+
energy_count = sum(1 for t in pool if t.energy is not None)
263271
present = set(camelot_keys)
264272
missing = [key for key in ALL_CAMELOT_KEYS if key not in present]
265273
bands = _bpm_bands(set_obj, bpms)
266274
logger.debug(
267-
"Set %s analyze_pool_gaps: pool=%d keyed=%d bpm=%d missing_keys=%d",
275+
"Set %s analyze_pool_gaps: pool=%d keyed=%d bpm=%d genre=%d energy=%d missing_keys=%d",
268276
set_obj.id,
269277
len(metas),
270278
len(camelot_keys),
271279
len(bpms),
280+
genre_count,
281+
energy_count,
272282
len(missing),
273283
)
274284
return {
275285
"pool_size": len(metas),
276286
"keyed_track_count": len(camelot_keys),
277287
"bpm_track_count": len(bpms),
288+
"genre_track_count": genre_count,
289+
"energy_track_count": energy_count,
290+
"missing_genre_count": len(metas) - genre_count,
291+
"missing_energy_count": len(metas) - energy_count,
278292
"missing_camelot_keys": missing,
279293
"bpm_bands": bands,
280294
"sparse_bands": [b for b in bands if b["count"] < SPARSE_BAND_THRESHOLD],

server/app/services/setbuilder/agent_tools_structural.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from app.models.set import Set
1717
from app.services.setbuilder.agent_common import AgentToolError, _ordered_slots, _pool_tracks
1818
from app.services.setbuilder.agent_tools_mutations import _insert_track_at
19+
from app.services.setbuilder.coverage import coverage_for_set
1920
from app.services.setbuilder.pass1_deterministic import AVG_TRACK_LENGTH_SEC, build_set
2021
from app.services.setbuilder.pass1_deterministic import _track_meta as _pass1_track_meta
2122

@@ -35,15 +36,27 @@ def _tool_autobuild(
3536
already honors locked slots and saved pairings. Runs with ``commit=False``
3637
so the agent turn commits/rolls back as one unit.
3738
"""
39+
# Coverage of the five required pool→builder fields BEFORE the rebuild, so the
40+
# agent can warn the DJ about under-enriched tracks (#542). Soft/advisory only:
41+
# autobuild proceeds regardless, mirroring the REST build's overridable gate.
42+
coverage = coverage_for_set(db, set_obj.id)
3843
result = build_set(db, set_obj, commit=False)
3944
affected = {slot.position for slot in result.slots}
4045
logger.info(
41-
"setbuilder autobuild: set %s rebuilt to %s slots (%s refinement iterations)",
46+
"setbuilder autobuild: set %s rebuilt to %s slots (%s refinement iterations); "
47+
"pool coverage ready=%s (%s/%s fully enriched)",
4248
set_obj.id,
4349
result.slot_count,
4450
result.iterations,
51+
coverage["ready"],
52+
coverage["fully_covered_count"],
53+
coverage["pool_size"],
4554
)
46-
return {"slot_count": result.slot_count, "iterations": result.iterations}, affected
55+
return {
56+
"slot_count": result.slot_count,
57+
"iterations": result.iterations,
58+
"coverage": coverage,
59+
}, affected
4760

4861

4962
def _duration_for(track) -> int:

0 commit comments

Comments
 (0)