gabriel / musehub public
elicitation_tools.py python
362 lines 14.2 KB
Raw
sha256:c2e5cf2d754ed2fe9a94e879d41ad12ff221d2ac4ac7b45247fcb981c76858bf docs: Section 8 database/migrations/backups — verified live… Sonnet 5 24 days ago
1 """Elicitation-powered tool executors for MCP 2025-11-25.
2
3 These tools use ``ToolCallContext.elicit_form()`` and ``elicit_url()`` to
4 collect structured input from users mid-tool-call. They require an active
5 session (``Mcp-Session-Id``) and a client that has declared elicitation
6 capability. When running without a session they degrade gracefully.
7
8 Tools in this module:
9
10 musehub_review_proposal_interactive
11 Form-mode: collects dimension focus and review depth before running a
12 deep musical divergence analysis of the proposal.
13
14 musehub_create_release_interactive
15 Form-mode: collects release metadata interactively, then creates the release.
16 """
17
18 import logging
19 from typing import TYPE_CHECKING
20
21 from musehub.types.json_types import JSONObject, JSONValue
22 from musehub.mcp.elicitation import SCHEMAS
23 from musehub.services.musehub_mcp_executor import MusehubToolResult, _check_db_available
24
25 if TYPE_CHECKING:
26 from musehub.mcp.context import ToolCallContext
27
28 logger = logging.getLogger(__name__)
29
30 # ── Interactive proposal review ───────────────────────────────────────────────
31
32 async def execute_review_proposal_interactive(
33 repo_id: str,
34 proposal_id: str,
35 *,
36 dimension: str | None = None,
37 depth: str | None = None,
38 ctx: "ToolCallContext",
39 ) -> MusehubToolResult:
40 """Review a proposal interactively by eliciting the reviewer's focus and depth.
41
42 Bypass path (no session needed): supply ``dimension`` and ``depth`` directly
43 to run the divergence analysis without any elicitation round-trip.
44
45 Elicitation path (session required): when bypass params are omitted and an
46 active session exists, a form collects dimension focus, review depth, and
47 optional reviewer notes.
48
49 Collects: dimension focus (melodic / harmonic / rhythmic / structural /
50 dynamic / all), review depth (quick / standard / thorough).
51
52 Args:
53 repo_id: Repository ID containing the proposal.
54 proposal_id: Proposal ID to review.
55 dimension: Bypass param — one of: melodic, harmonic, rhythmic, structural, dynamic, all.
56 depth: Bypass param — one of: quick, standard, thorough.
57 ctx: Tool call context (session required only when bypass params are absent).
58 """
59 check_harmonic = True
60 check_rhythmic = True
61 note = ""
62
63 # ── Bypass: dimension + depth provided directly ───────────────────────────
64 if dimension is not None or depth is not None:
65 dimension = dimension or "all"
66 depth = depth or "standard"
67 elif not ctx.has_session:
68 # No session and no bypass params — return actionable guide.
69 return MusehubToolResult(
70 ok=True,
71 data={
72 "mode": "schema_guide",
73 "message": (
74 "No active MCP session. Pass dimension and depth to bypass elicitation: "
75 "musehub_review_proposal_interactive(repo_id=..., proposal_id=..., dimension='all', depth='standard')"
76 ),
77 "dimension_options": ["melodic", "harmonic", "rhythmic", "structural", "dynamic", "all"],
78 "depth_options": ["quick", "standard", "thorough"],
79 },
80 )
81 else:
82 # ── Elicitation path: active session ──────────────────────────────────
83 prefs = await ctx.elicit_form(
84 SCHEMAS["proposal_review_focus"],
85 message=(
86 f"I'll review proposal {proposal_id}. How would you like me to focus the review? "
87 "Choose a musical dimension and depth level."
88 ),
89 )
90
91 if prefs is None:
92 return MusehubToolResult(
93 ok=False,
94 error_code="elicitation_declined",
95 error_message="User declined the proposal review focus form.",
96 )
97
98 dimension = str(prefs.get("dimension_focus", "all"))
99 depth = str(prefs.get("review_depth", "standard"))
100 check_harmonic = bool(prefs.get("check_harmonic_tension", True))
101 check_rhythmic = bool(prefs.get("check_rhythmic_consistency", True))
102 note = str(prefs.get("reviewer_note", ""))
103
104 await ctx.progress("review", 0, 3, f"Analysing proposal {proposal_id}… ({dimension} / {depth})")
105
106 # Run the divergence analysis via the existing executor.
107 from musehub.services.musehub_mcp_executor import _check_db_available
108 from musehub.db.database import AsyncSessionLocal
109 from musehub.services import musehub_proposals, musehub_divergence
110
111 db_ok = _check_db_available()
112 if db_ok is not None:
113 return MusehubToolResult(
114 ok=False,
115 error_code="db_unavailable",
116 error_message="Database unavailable.",
117 )
118
119 async with AsyncSessionLocal() as db:
120 proposal = await musehub_proposals.get_proposal(db, repo_id, proposal_id)
121 if proposal is None:
122 return MusehubToolResult(
123 ok=False,
124 error_code="proposal_not_found",
125 error_message=f"Proposal {proposal_id} not found in repo {repo_id}.",
126 hint="Call musehub_list_proposals() to see open proposals.",
127 )
128
129 await ctx.progress("review", 1, 3, "Computing branch divergence…")
130
131 try:
132 div_result = await musehub_divergence.compute_hub_divergence(
133 db, repo_id=repo_id, branch_a=proposal.from_branch, branch_b=proposal.to_branch
134 )
135 except ValueError as e:
136 div_result = None
137 div_error = str(e)
138
139 await ctx.progress("review", 2, 3, "Building review report…")
140
141 review: JSONObject = {
142 "proposal_id": proposal_id,
143 "repo_id": repo_id,
144 "from_branch": proposal.from_branch,
145 "to_branch": proposal.to_branch,
146 "focus_dimension": dimension,
147 "depth": depth,
148 "reviewer_note": note or None,
149 }
150
151 if div_result:
152 dims: list[JSONValue] = [
153 {
154 "dimension": d.dimension,
155 "score": d.score,
156 "level": d.level.value,
157 "description": d.description,
158 }
159 for d in div_result.dimensions
160 if dimension == "all" or d.dimension == dimension
161 ]
162 review["overall_score"] = div_result.overall_score
163 review["common_ancestor"] = div_result.common_ancestor
164 review["dimensions"] = dims
165
166 findings: list[str] = []
167 for d in div_result.dimensions:
168 if d.score > 0.7 and (dimension == "all" or d.dimension == dimension):
169 findings.append(
170 f"⚠️ HIGH {d.dimension} divergence ({d.score:.0%}): {d.description}"
171 )
172 elif d.score > 0.4 and depth == "thorough":
173 findings.append(
174 f"ℹ️ Moderate {d.dimension} divergence ({d.score:.0%}): {d.description}"
175 )
176 if check_harmonic and div_result.overall_score > 0.5:
177 findings.append(
178 "🎵 Harmonic tension check: significant harmonic changes detected — "
179 "verify voice-leading and resolution in the bridge/chorus."
180 )
181 if check_rhythmic:
182 rhythmic = next((d for d in div_result.dimensions if d.dimension == "rhythmic"), None)
183 if rhythmic and rhythmic.score > 0.3:
184 findings.append(
185 f"🥁 Rhythmic consistency: score {rhythmic.score:.0%} — "
186 "check for tempo drift or conflicting groove patterns."
187 )
188 review["findings"] = list(findings) if findings else ["No significant issues detected."]
189 review["recommendation"] = (
190 "APPROVE" if div_result.overall_score < 0.3
191 else "REQUEST_CHANGES" if div_result.overall_score > 0.6
192 else "COMMENT"
193 )
194 else:
195 review["divergence_error"] = div_error if "div_error" in dir() else "unknown"
196 review["recommendation"] = "COMMENT"
197 review["findings"] = ["Could not compute divergence — check that both branches have commits."]
198
199 await ctx.progress("review", 3, 3, "Review complete.")
200 return MusehubToolResult(ok=True, data=review)
201
202 # ── Interactive release creation ──────────────────────────────────────────────
203
204 async def execute_create_release_interactive(
205 repo_id: str,
206 *,
207 tag: str | None = None,
208 title: str | None = None,
209 notes: str | None = None,
210 ctx: "ToolCallContext",
211 ) -> MusehubToolResult:
212 """Create a release interactively or directly via bypass params.
213
214 Bypass path (no session needed): supply ``tag`` (required), plus optional
215 ``title`` and ``notes`` to create the release without any elicitation.
216
217 Elicitation path (session required): when bypass params are omitted and an
218 active MCP session exists, a form collects tag, title, release notes, and
219 pre-release flag, followed by an optional Spotify OAuth URL prompt.
220
221 No-session, no-params path: returns a schema guide listing every field.
222
223 Args:
224 repo_id: Repository to tag the release against.
225 tag: Bypass param — semantic version tag (e.g. "v1.2.0").
226 title: Bypass param — human-readable release title.
227 notes: Bypass param — release notes / changelog body.
228 ctx: Tool call context (session required only when bypass params absent).
229 """
230 highlight = ""
231
232 # ── Bypass: tag provided directly ────────────────────────────────────────
233 if tag is not None:
234 resolved_title = title or tag
235 release_notes = notes or ""
236 await ctx.progress("release", 0, 2, f"Creating release {tag}…")
237
238 from musehub.mcp.write_tools.releases import execute_create_release
239
240 result = await execute_create_release(
241 repo_id=repo_id,
242 tag=tag,
243 title=resolved_title,
244 body=release_notes,
245 commit_id=None,
246 channel="stable",
247 actor=ctx.user_id or "",
248 )
249
250 await ctx.progress("release", 2, 2, "Done.")
251
252 if not result.ok:
253 return result
254
255 release_data = result.data or {}
256 return MusehubToolResult(
257 ok=True,
258 data={
259 **release_data,
260 "workflow_hint": (
261 "Release published. Use musehub_create_release_interactive for a guided release flow."
262 ),
263 },
264 )
265
266 # ── No session and no bypass params: return field guide ──────────────────
267 if not ctx.has_session:
268 return MusehubToolResult(
269 ok=True,
270 data={
271 "mode": "schema_guide",
272 "message": (
273 "No active MCP session. Pass 'tag' to bypass elicitation: "
274 "musehub_create_release_interactive(repo_id=..., tag='v1.0.0', title='...', notes='...')"
275 ),
276 "fields": {
277 "tag": {"type": "string", "required": True, "example": "v1.0.0"},
278 "title": {"type": "string", "required": False, "example": "First release"},
279 "notes": {"type": "string", "required": False, "example": "Bug fixes and improvements"},
280 },
281 },
282 )
283
284 # ── Elicitation path: active session ──────────────────────────────────────
285 await ctx.progress("release", 0, 3, "Collecting release metadata…")
286
287 # Step 1: collect release metadata via form elicitation.
288 prefs = await ctx.elicit_form(
289 SCHEMAS["release_metadata"],
290 message=(
291 f"Let's create a release for repo {repo_id}. "
292 "Fill in the release details below."
293 ),
294 )
295
296 if prefs is None:
297 return MusehubToolResult(
298 ok=False,
299 error_code="elicitation_declined",
300 error_message="User declined the release metadata form.",
301 )
302
303 tag = str(prefs.get("tag", "v1.0.0"))
304 resolved_title = str(prefs.get("title", tag))
305 release_notes = str(prefs.get("release_notes", ""))
306 channel_raw = prefs.get("channel", "stable")
307 channel = str(channel_raw) if isinstance(channel_raw, str) else "stable"
308 highlight = str(prefs.get("highlight", ""))
309
310 if highlight:
311 release_notes = f"**Highlight:** {highlight}\n\n{release_notes}".strip()
312
313 await ctx.progress("release", 1, 3, f"Creating release {tag}…")
314
315 # Create the release using the existing executor.
316 from musehub.mcp.write_tools.releases import execute_create_release
317
318 result = await execute_create_release(
319 repo_id=repo_id,
320 tag=tag,
321 title=resolved_title,
322 body=release_notes,
323 commit_id=None,
324 channel=channel,
325 actor=ctx.user_id or "",
326 )
327
328 if not result.ok:
329 return result
330
331 await ctx.progress("release", 2, 3, "Release created. Checking platform connections…")
332
333 # Step 2: offer streaming platform connection (URL elicitation), non-blocking.
334 if ctx.has_session and ctx.session and ctx.session.supports_elicitation_url():
335 import secrets
336 elicitation_id = secrets.token_urlsafe(16)
337 spotify_url = oauth_connect_url("Spotify", elicitation_id)
338
339 # Non-blocking: just offer it — tool returns success either way.
340 await ctx.elicit_url(
341 spotify_url,
342 message=(
343 "Release created! Would you like to distribute it to Spotify? "
344 "Click through to connect your Spotify for Artists account."
345 ),
346 elicitation_id=elicitation_id,
347 )
348
349 release_data = result.data or {}
350 release_data["highlight"] = highlight or None
351
352 await ctx.progress("release", 3, 3, "Done.")
353
354 return MusehubToolResult(
355 ok=True,
356 data={
357 **release_data,
358 "workflow_hint": (
359 "Release published. Use musehub_create_release_interactive for a guided release flow."
360 ),
361 },
362 )
File History 2 commits
sha256:c2e5cf2d754ed2fe9a94e879d41ad12ff221d2ac4ac7b45247fcb981c76858bf docs: Section 8 database/migrations/backups — verified live… Sonnet 5 24 days ago
sha256:80e1a60a39562f6e616aaafbb27487f03abbec7d8ffc6164302b7a0c0bfc63ee docs: check off Section 0 items verified in inventory doc, … Sonnet 5 24 days ago