elicitation_tools.py
python
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