From ae88c6e5331dcddfe13cc5c9847a21dc5874b7ce Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 13 Sep 2026 21:20:57 +0200 Subject: [PATCH] Enforce user locks for AI updates --- .../Domain/SongProject.swift | 28 ++ .../Domain/SongProjectUpdateScope.swift | 42 +++ .../Services/AI/AIService.swift | 15 -- .../AI/AutoSongProjectDecisionsDirector.swift | 12 +- .../AI/ExistingLyricsRevisionDirector.swift | 18 +- .../AI/SongProjectAIUpdateMerger.swift | 251 ++++++++++++++++++ .../AI/SongProjectGenerationDirector.swift | 34 +-- .../AI/StructuredAIResponseSchema.swift | 11 +- ...utoSongProjectDecisionsDirectorTests.swift | 23 ++ .../ExistingLyricsRevisionDirectorTests.swift | 24 ++ .../SongProjectGenerationDirectorTests.swift | 36 +++ .../StructuredAIResponseSchemaTests.swift | 61 ++++- docs/ARCHITECTURE.md | 3 + docs/DATA_MODEL.md | 6 + docs/TASKS.md | 2 +- 15 files changed, 507 insertions(+), 59 deletions(-) create mode 100644 Sources/MusicAssistantCore/Domain/SongProjectUpdateScope.swift create mode 100644 Sources/MusicAssistantCore/Services/AI/SongProjectAIUpdateMerger.swift diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index ae06755..32000c7 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -23,6 +23,7 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable { public var lyrics: Lyrics public var productionDirections: [ProductionDirection] public var productionMode: ControlMode? + public var userLocks: SongProjectUserLocks? public var sunoOutput: SunoOutput? public var versions: [SongVersion] public let createdAt: Date @@ -51,6 +52,7 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable { lyrics: Lyrics = Lyrics(), productionDirections: [ProductionDirection] = [], productionMode: ControlMode? = .auto, + userLocks: SongProjectUserLocks? = SongProjectUserLocks(), sunoOutput: SunoOutput? = nil, versions: [SongVersion] = [], createdAt: Date = Date(), @@ -78,6 +80,7 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable { self.lyrics = lyrics self.productionDirections = productionDirections self.productionMode = productionMode + self.userLocks = userLocks self.sunoOutput = sunoOutput self.versions = versions self.createdAt = createdAt @@ -102,6 +105,26 @@ public extension SongProject { productionMode = mode } + var lockedUpdateScopes: [SongProjectUpdateScope] { + userLocks?.scopes ?? [] + } + + func isUpdateScopeLocked(_ scope: SongProjectUpdateScope) -> Bool { + userLocks?.contains(scope) ?? false + } + + mutating func lockUpdateScope(_ scope: SongProjectUpdateScope) { + var userLocks = userLocks ?? SongProjectUserLocks() + userLocks.insert(scope) + self.userLocks = userLocks + } + + mutating func unlockUpdateScope(_ scope: SongProjectUpdateScope) { + guard var userLocks else { return } + userLocks.remove(scope) + self.userLocks = userLocks + } + var orderedSections: [SongSection] { sections .enumerated() @@ -393,6 +416,7 @@ public extension SongProject { lyrics = snapshot.lyrics productionDirections = snapshot.productionDirections productionMode = snapshot.productionMode + userLocks = snapshot.userLocks sunoOutput = snapshot.sunoOutput self.updatedAt = updatedAt } @@ -795,6 +819,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { public var lyrics: Lyrics public var productionDirections: [ProductionDirection] public var productionMode: ControlMode? + public var userLocks: SongProjectUserLocks? public var sunoOutput: SunoOutput? public init( @@ -819,6 +844,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { lyrics: Lyrics = Lyrics(), productionDirections: [ProductionDirection] = [], productionMode: ControlMode? = .auto, + userLocks: SongProjectUserLocks? = SongProjectUserLocks(), sunoOutput: SunoOutput? = nil ) { self.title = title @@ -842,6 +868,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { self.lyrics = lyrics self.productionDirections = productionDirections self.productionMode = productionMode + self.userLocks = userLocks self.sunoOutput = sunoOutput } @@ -868,6 +895,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { lyrics: project.lyrics, productionDirections: project.productionDirections, productionMode: project.productionMode, + userLocks: project.userLocks, sunoOutput: project.sunoOutput ) } diff --git a/Sources/MusicAssistantCore/Domain/SongProjectUpdateScope.swift b/Sources/MusicAssistantCore/Domain/SongProjectUpdateScope.swift new file mode 100644 index 0000000..645deb0 --- /dev/null +++ b/Sources/MusicAssistantCore/Domain/SongProjectUpdateScope.swift @@ -0,0 +1,42 @@ +import Foundation + +public enum SongProjectUpdateScope: String, CaseIterable, Codable, Equatable, Hashable, Sendable { + case duration + case genres + case instruments + case vocals + case languages + case lyrics + case structure + case arrangement + case musicalParameters + case emotionalArc + case productionDirections + case sunoOutput +} + +public struct SongProjectUserLocks: Codable, Equatable, Sendable { + public var scopes: [SongProjectUpdateScope] + + public init(scopes: [SongProjectUpdateScope] = []) { + self.scopes = Self.uniqueScopes(scopes) + } + + public func contains(_ scope: SongProjectUpdateScope) -> Bool { + scopes.contains(scope) + } + + public mutating func insert(_ scope: SongProjectUpdateScope) { + guard !contains(scope) else { return } + scopes.append(scope) + } + + public mutating func remove(_ scope: SongProjectUpdateScope) { + scopes.removeAll { $0 == scope } + } + + private static func uniqueScopes(_ scopes: [SongProjectUpdateScope]) -> [SongProjectUpdateScope] { + var seen = Set() + return scopes.filter { seen.insert($0).inserted } + } +} diff --git a/Sources/MusicAssistantCore/Services/AI/AIService.swift b/Sources/MusicAssistantCore/Services/AI/AIService.swift index 52f132c..2f5cd49 100644 --- a/Sources/MusicAssistantCore/Services/AI/AIService.swift +++ b/Sources/MusicAssistantCore/Services/AI/AIService.swift @@ -166,21 +166,6 @@ public struct SongProjectUpdateRequest: Equatable, Sendable { } } -public enum SongProjectUpdateScope: String, Codable, Equatable, Sendable { - case duration - case genres - case instruments - case vocals - case languages - case lyrics - case structure - case arrangement - case musicalParameters - case emotionalArc - case productionDirections - case sunoOutput -} - public struct SongProjectUpdateResult: Equatable, Sendable { public var project: SongProject public var changedScopes: [SongProjectUpdateScope] diff --git a/Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift b/Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift index 5b9dd83..8155ee9 100644 --- a/Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift +++ b/Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift @@ -52,22 +52,24 @@ public final class AutoSongProjectDecisionsDirector: Sendable { private func automaticScopes(for project: SongProject) -> [SongProjectUpdateScope] { var scopes: [SongProjectUpdateScope] = [] - if project.isStructureAutomatic { + if project.isStructureAutomatic && !project.isUpdateScopeLocked(.structure) { scopes.append(.structure) } - if project.instruments.contains(where: { $0.selected && $0.autoArrangementEnabled }) { + if !project.isUpdateScopeLocked(.arrangement) && + project.instruments.contains(where: { $0.selected && $0.autoArrangementEnabled }) { scopes.append(.arrangement) } - if project.bpm?.mode != .manual || + if !project.isUpdateScopeLocked(.musicalParameters) && + (project.bpm?.mode != .manual || project.key?.mode != .manual || project.scale?.mode != .manual || - project.maqam?.mode != .manual { + project.maqam?.mode != .manual) { scopes.append(.musicalParameters) } - if project.isProductionAutomatic { + if project.isProductionAutomatic && !project.isUpdateScopeLocked(.productionDirections) { scopes.append(.productionDirections) } diff --git a/Sources/MusicAssistantCore/Services/AI/ExistingLyricsRevisionDirector.swift b/Sources/MusicAssistantCore/Services/AI/ExistingLyricsRevisionDirector.swift index 99878e1..bac0a02 100644 --- a/Sources/MusicAssistantCore/Services/AI/ExistingLyricsRevisionDirector.swift +++ b/Sources/MusicAssistantCore/Services/AI/ExistingLyricsRevisionDirector.swift @@ -30,6 +30,10 @@ public final class ExistingLyricsRevisionDirector: Sendable { throw ExistingLyricsRevisionDirectorError.emptySourceLyrics } + guard !project.isUpdateScopeLocked(.lyrics) else { + return SongProjectUpdateResult(project: project) + } + let request = LyricsRevisionRequest( context: AIRequestContext( userInstruction: trimmedInstruction, @@ -42,14 +46,14 @@ public final class ExistingLyricsRevisionDirector: Sendable { ) let result = try await aiService.reviseLyrics(from: request) - var updatedProject = project - updatedProject.lyrics = result.lyrics - updatedProject.updatedAt = revisedAt - - return SongProjectUpdateResult( - project: updatedProject, + var proposedProject = project + proposedProject.lyrics = result.lyrics + return SongProjectAIUpdateMerger().apply( + snapshot: SongProjectSnapshot(project: proposedProject), changedScopes: [.lyrics], - notes: result.notes + notes: result.notes, + to: project, + updatedAt: revisedAt ) } } diff --git a/Sources/MusicAssistantCore/Services/AI/SongProjectAIUpdateMerger.swift b/Sources/MusicAssistantCore/Services/AI/SongProjectAIUpdateMerger.swift new file mode 100644 index 0000000..41a195f --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/SongProjectAIUpdateMerger.swift @@ -0,0 +1,251 @@ +import Foundation + +public struct SongProjectAIUpdateMerger: Sendable { + public init() {} + + public func apply( + snapshot: SongProjectSnapshot, + changedScopes: [SongProjectUpdateScope], + notes: [String] = [], + followUpQuestions: [String] = [], + to existingProject: SongProject, + updatedAt: Date = Date() + ) -> SongProjectUpdateResult { + var updatedProject = existingProject + var appliedScopes: [SongProjectUpdateScope] = [] + + for scope in uniqueScopes(changedScopes) where !existingProject.isUpdateScopeLocked(scope) { + let projectBeforeScope = updatedProject + apply(scope, snapshot: snapshot, to: &updatedProject) + + if didChange(scope, from: projectBeforeScope, to: updatedProject) { + appliedScopes.append(scope) + } + } + + updatedProject.updatedAt = updatedAt + return SongProjectUpdateResult( + project: updatedProject, + changedScopes: appliedScopes, + notes: notes, + followUpQuestions: followUpQuestions + ) + } + + private func apply( + _ scope: SongProjectUpdateScope, + snapshot: SongProjectSnapshot, + to project: inout SongProject + ) { + switch scope { + case .duration: + project.duration = snapshot.duration + case .genres: + project.genres = snapshot.genres + case .instruments: + project.instruments = mergedInstrumentSelections( + current: project.instruments, + proposed: snapshot.instruments + ) + case .vocals: + project.vocalists = snapshot.vocalists + case .languages: + project.languages = snapshot.languages + project.dialects = snapshot.dialects + project.arabicPronunciationSettings = snapshot.arabicPronunciationSettings + case .lyrics: + project.lyrics = snapshot.lyrics + case .structure: + guard project.isStructureAutomatic else { return } + project.sections = sections( + snapshot.sections, + preservingProductionDirectionsFrom: project.sections + ) + case .arrangement: + project.instruments = mergedArrangement( + current: project.instruments, + proposed: snapshot.instruments + ) + case .musicalParameters: + project.bpm = automaticValue(current: project.bpm, proposed: snapshot.bpm) + project.key = automaticValue(current: project.key, proposed: snapshot.key) + project.scale = automaticValue(current: project.scale, proposed: snapshot.scale) + project.maqam = automaticValue(current: project.maqam, proposed: snapshot.maqam) + case .emotionalArc: + project.emotionalArc = snapshot.emotionalArc + case .productionDirections: + guard project.isProductionAutomatic else { return } + project.productionDirections = snapshot.productionDirections + project.sections = sections( + project.sections, + applyingProductionDirectionsFrom: snapshot.sections + ) + case .sunoOutput: + project.sunoOutput = snapshot.sunoOutput + } + } + + private func mergedInstrumentSelections( + current: [InstrumentTrack], + proposed: [InstrumentTrack] + ) -> [InstrumentTrack] { + var proposedByID: [String: InstrumentTrack] = [:] + for proposedTrack in proposed { + proposedByID[proposedTrack.instrumentId] = proposedTrack + } + + let mergedTracks = current.map { currentTrack in + guard currentTrack.autoArrangementEnabled, + let proposedTrack = proposedByID[currentTrack.instrumentId] + else { + return currentTrack + } + + var mergedTrack = currentTrack + mergedTrack.selected = proposedTrack.selected + mergedTrack.variant = proposedTrack.variant + return mergedTrack + } + + var knownInstrumentIDs = Set(current.map(\.instrumentId)) + let newTracks = proposed.filter { knownInstrumentIDs.insert($0.instrumentId).inserted } + return mergedTracks + newTracks + } + + private func mergedArrangement( + current: [InstrumentTrack], + proposed: [InstrumentTrack] + ) -> [InstrumentTrack] { + var proposedByID: [String: InstrumentTrack] = [:] + for proposedTrack in proposed { + proposedByID[proposedTrack.instrumentId] = proposedTrack + } + + return current.map { currentTrack in + guard currentTrack.selected, + currentTrack.autoArrangementEnabled, + let proposedTrack = proposedByID[currentTrack.instrumentId] + else { + return currentTrack + } + + var mergedTrack = currentTrack + mergedTrack.playingStyle = proposedTrack.playingStyle + mergedTrack.role = proposedTrack.role + mergedTrack.placements = proposedTrack.placements + return mergedTrack + } + } + + private func automaticValue( + current: ManualAutoValue?, + proposed: ManualAutoValue? + ) -> ManualAutoValue? where Value: Codable & Equatable & Sendable { + guard current?.mode != .manual, let proposed else { + return current + } + + return ManualAutoValue(mode: .auto, value: proposed.value) + } + + private func sections( + _ current: [SongSection], + applyingProductionDirectionsFrom proposed: [SongSection] + ) -> [SongSection] { + var proposedByID: [String: SongSection] = [:] + for proposedSection in proposed { + proposedByID[proposedSection.id] = proposedSection + } + + return current.map { section in + guard let proposedSection = proposedByID[section.id] else { return section } + var updatedSection = section + updatedSection.productionDirection = proposedSection.productionDirection + return updatedSection + } + } + + private func sections( + _ proposed: [SongSection], + preservingProductionDirectionsFrom current: [SongSection] + ) -> [SongSection] { + var currentByID: [String: SongSection] = [:] + for currentSection in current { + currentByID[currentSection.id] = currentSection + } + + return proposed.map { section in + var updatedSection = section + updatedSection.productionDirection = currentByID[section.id]?.productionDirection + return updatedSection + } + } + + private func didChange( + _ scope: SongProjectUpdateScope, + from current: SongProject, + to updated: SongProject + ) -> Bool { + switch scope { + case .duration: + return current.duration != updated.duration + case .genres: + return current.genres != updated.genres + case .instruments, .arrangement: + return current.instruments != updated.instruments + case .vocals: + return current.vocalists != updated.vocalists + case .languages: + return current.languages != updated.languages || + current.dialects != updated.dialects || + current.arabicPronunciationSettings != updated.arabicPronunciationSettings + case .lyrics: + return current.lyrics != updated.lyrics + case .structure: + return sectionsDifferIgnoringProductionDirections(current.sections, updated.sections) + case .musicalParameters: + return current.bpm != updated.bpm || + current.key != updated.key || + current.scale != updated.scale || + current.maqam != updated.maqam + case .emotionalArc: + return current.emotionalArc != updated.emotionalArc + case .productionDirections: + return current.productionDirections != updated.productionDirections || + productionDirectionsDiffer(in: current.sections, and: updated.sections) + case .sunoOutput: + return current.sunoOutput != updated.sunoOutput + } + } + + private func uniqueScopes(_ scopes: [SongProjectUpdateScope]) -> [SongProjectUpdateScope] { + var seen = Set() + return scopes.filter { seen.insert($0).inserted } + } + + private func sectionsDifferIgnoringProductionDirections( + _ current: [SongSection], + _ updated: [SongSection] + ) -> Bool { + guard current.count == updated.count else { return true } + + return zip(current, updated).contains { currentSection, updatedSection in + var currentWithoutProduction = currentSection + var updatedWithoutProduction = updatedSection + currentWithoutProduction.productionDirection = nil + updatedWithoutProduction.productionDirection = nil + return currentWithoutProduction != updatedWithoutProduction + } + } + + private func productionDirectionsDiffer( + in current: [SongSection], + and updated: [SongSection] + ) -> Bool { + guard current.count == updated.count else { return true } + + return zip(current, updated).contains { currentSection, updatedSection in + currentSection.productionDirection != updatedSection.productionDirection + } + } +} diff --git a/Sources/MusicAssistantCore/Services/AI/SongProjectGenerationDirector.swift b/Sources/MusicAssistantCore/Services/AI/SongProjectGenerationDirector.swift index dae50da..6c91e7f 100644 --- a/Sources/MusicAssistantCore/Services/AI/SongProjectGenerationDirector.swift +++ b/Sources/MusicAssistantCore/Services/AI/SongProjectGenerationDirector.swift @@ -64,32 +64,18 @@ public final class SongProjectGenerationDirector: Sendable { return project } - return SongProject( - id: seedProject.id, - title: generatedProject.title, - idea: normalizedIdea, - duration: generatedProject.duration, - conversationMode: discussionMode, - languages: generatedProject.languages, - dialects: generatedProject.dialects, - arabicPronunciationSettings: generatedProject.arabicPronunciationSettings, - genres: generatedProject.genres, - moods: generatedProject.moods, - emotionalArc: generatedProject.emotionalArc, - bpm: generatedProject.bpm, - key: generatedProject.key, - scale: generatedProject.scale, - maqam: generatedProject.maqam, - sections: generatedProject.sections, - instruments: generatedProject.instruments, - vocalists: generatedProject.vocalists, - lyrics: generatedProject.lyrics, - productionDirections: generatedProject.productionDirections, - sunoOutput: generatedProject.sunoOutput, - versions: seedProject.versions, - createdAt: seedProject.createdAt, + let update = SongProjectAIUpdateMerger().apply( + snapshot: SongProjectSnapshot(project: generatedProject), + changedScopes: SongProjectUpdateScope.allCases, + to: seedProject, updatedAt: generatedAt ) + var project = update.project + project.title = generatedProject.title + project.idea = normalizedIdea + project.conversationMode = discussionMode + project.moods = generatedProject.moods + return project } } diff --git a/Sources/MusicAssistantCore/Services/AI/StructuredAIResponseSchema.swift b/Sources/MusicAssistantCore/Services/AI/StructuredAIResponseSchema.swift index d6d590e..22c7b98 100644 --- a/Sources/MusicAssistantCore/Services/AI/StructuredAIResponseSchema.swift +++ b/Sources/MusicAssistantCore/Services/AI/StructuredAIResponseSchema.swift @@ -24,14 +24,13 @@ public struct StructuredSongProjectUpdateResponse: Codable, Equatable, Sendable updating existingProject: SongProject, updatedAt: Date = Date() ) -> SongProjectUpdateResult { - var updatedProject = existingProject - updatedProject.restoreSnapshot(project, updatedAt: updatedAt) - - return SongProjectUpdateResult( - project: updatedProject, + SongProjectAIUpdateMerger().apply( + snapshot: project, changedScopes: changedScopes, notes: notes, - followUpQuestions: followUpQuestions + followUpQuestions: followUpQuestions, + to: existingProject, + updatedAt: updatedAt ) } } diff --git a/Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift b/Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift index 0b44efb..1d4a205 100644 --- a/Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift +++ b/Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift @@ -188,6 +188,29 @@ final class AutoSongProjectDecisionsDirectorTests: XCTestCase { XCTAssertEqual(result.project.productionDirections, project.productionDirections) XCTAssertEqual(result.changedScopes, [SongProjectUpdateScope.structure]) } + + func testAutomaticDecisionsDoNotCallAIForLockedAutomaticScopes() async throws { + let project = SongProject( + title: "Locked Auto Project", + idea: "Do not update the tempo", + bpm: ManualAutoValue(mode: .auto), + key: ManualAutoValue(mode: .manual, value: "C"), + scale: ManualAutoValue(mode: .manual, value: "minor"), + maqam: ManualAutoValue(mode: .manual, value: "Rast"), + structureMode: .manual, + productionMode: .manual, + userLocks: SongProjectUserLocks(scopes: [.musicalParameters]) + ) + let aiService = RecordingAutoDecisionsAIService(result: SongProjectUpdateResult(project: project)) + let director = AutoSongProjectDecisionsDirector(aiService: aiService) + + let result = try await director.applyAutomaticDecisions(to: project) + let requestCount = await aiService.recordedRequests.count + + XCTAssertEqual(requestCount, 0) + XCTAssertEqual(result.project, project) + XCTAssertTrue(result.changedScopes.isEmpty) + } } private actor RecordingAutoDecisionsAIService: AIService { diff --git a/Tests/MusicAssistantCoreTests/ExistingLyricsRevisionDirectorTests.swift b/Tests/MusicAssistantCoreTests/ExistingLyricsRevisionDirectorTests.swift index 2615f85..9f6b3d6 100644 --- a/Tests/MusicAssistantCoreTests/ExistingLyricsRevisionDirectorTests.swift +++ b/Tests/MusicAssistantCoreTests/ExistingLyricsRevisionDirectorTests.swift @@ -137,6 +137,30 @@ final class ExistingLyricsRevisionDirectorTests: XCTestCase { let requestCount = await aiService.recordedRevisionRequests.count XCTAssertEqual(requestCount, 0) } + + func testReviseExistingLyricsDoesNotCallAIWhenLyricsAreLocked() async throws { + let aiService = RecordingLyricsAIService( + revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused")) + ) + let director = ExistingLyricsRevisionDirector(aiService: aiService) + let project = SongProject( + title: "Locked Lyrics", + idea: "Keep the words", + lyrics: Lyrics(text: "User-approved lyrics"), + userLocks: SongProjectUserLocks(scopes: [.lyrics]) + ) + + let result = try await director.reviseExistingLyrics( + in: project, + instruction: "Improve the chorus.", + mode: .improve + ) + let requestCount = await aiService.recordedRevisionRequests.count + + XCTAssertEqual(requestCount, 0) + XCTAssertEqual(result.project, project) + XCTAssertTrue(result.changedScopes.isEmpty) + } } private actor RecordingLyricsAIService: AIService { diff --git a/Tests/MusicAssistantCoreTests/SongProjectGenerationDirectorTests.swift b/Tests/MusicAssistantCoreTests/SongProjectGenerationDirectorTests.swift index 65b12e2..be31491 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectGenerationDirectorTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectGenerationDirectorTests.swift @@ -105,6 +105,42 @@ final class SongProjectGenerationDirectorTests: XCTestCase { let requestCount = await aiService.recordedGenerationRequests.count XCTAssertEqual(requestCount, 0) } + + func testGenerateCompleteProjectPreservesLockedAndManualSeedValues() async throws { + let seedProject = SongProject( + id: "locked-seed", + title: "Seed", + idea: "Original", + genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")], + bpm: ManualAutoValue(mode: .manual, value: 96), + lyrics: Lyrics(text: "User-approved lyrics"), + userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics]) + ) + let aiService = RecordingGenerationAIService( + generationResult: SongProjectGenerationResult( + project: SongProject( + title: "Generated Title", + idea: "Generated idea", + genres: [GenreStyle(id: "rock", name: "Rock")], + bpm: ManualAutoValue(mode: .auto, value: 120), + lyrics: Lyrics(text: "AI lyrics") + ) + ) + ) + let director = SongProjectGenerationDirector(aiService: aiService) + + let result = try await director.generateCompleteProject( + fromIdea: "Generate around the locked choices", + seedProject: seedProject + ) + + XCTAssertEqual(result.project.title, "Generated Title") + XCTAssertEqual(result.project.idea, "Generated idea") + XCTAssertEqual(result.project.genres, seedProject.genres) + XCTAssertEqual(result.project.lyrics, seedProject.lyrics) + XCTAssertEqual(result.project.bpm, seedProject.bpm) + XCTAssertEqual(result.project.userLocks, seedProject.userLocks) + } } private actor RecordingGenerationAIService: AIService { diff --git a/Tests/MusicAssistantCoreTests/StructuredAIResponseSchemaTests.swift b/Tests/MusicAssistantCoreTests/StructuredAIResponseSchemaTests.swift index 5e999dc..1e5dfcc 100644 --- a/Tests/MusicAssistantCoreTests/StructuredAIResponseSchemaTests.swift +++ b/Tests/MusicAssistantCoreTests/StructuredAIResponseSchemaTests.swift @@ -105,10 +105,69 @@ final class StructuredAIResponseSchemaTests: XCTestCase { XCTAssertEqual(result.project.createdAt, createdAt) XCTAssertEqual(result.project.updatedAt, updatedAt) XCTAssertEqual(result.project.versions, [version]) - XCTAssertEqual(result.project.title, "Updated") + XCTAssertEqual(result.project.title, "Original") XCTAssertEqual(result.project.lyrics.text, "Updated lyrics") XCTAssertEqual(result.changedScopes, [.genres, .lyrics]) XCTAssertEqual(result.notes, ["Proposed focused update."]) XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"]) } + + func testStructuredResponsePreservesLockedScopesAndManualValues() { + let section = SongSection(id: "verse", type: .verse, title: "Manual Verse") + let manualInstrument = InstrumentTrack( + instrumentId: "oud", + selected: true, + role: "Manual lead", + autoArrangementEnabled: false, + placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")] + ) + let existingProject = SongProject( + title: "Original Title", + idea: "Original Idea", + genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")], + bpm: ManualAutoValue(mode: .manual, value: 96), + key: ManualAutoValue(mode: .auto), + structureMode: .manual, + sections: [section], + instruments: [manualInstrument], + lyrics: Lyrics(text: "Locked lyrics"), + userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics]) + ) + let response = StructuredSongProjectUpdateResponse( + project: SongProjectSnapshot( + title: "AI Title", + idea: "AI Idea", + genres: [GenreStyle(id: "rock", name: "Rock")], + bpm: ManualAutoValue(mode: .auto, value: 120), + key: ManualAutoValue(mode: .auto, value: "A"), + sections: [SongSection(id: "chorus", type: .chorus, title: "AI Chorus")], + instruments: [ + InstrumentTrack( + instrumentId: "oud", + selected: true, + role: "AI lead", + placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI entry")] + ) + ], + lyrics: Lyrics(text: "AI lyrics") + ), + changedScopes: [.genres, .lyrics, .instruments, .musicalParameters, .structure, .arrangement] + ) + + let result = response.makeUpdateResult( + updating: existingProject, + updatedAt: Date(timeIntervalSince1970: 500) + ) + + XCTAssertEqual(result.project.title, "Original Title") + XCTAssertEqual(result.project.idea, "Original Idea") + XCTAssertEqual(result.project.genres, existingProject.genres) + XCTAssertEqual(result.project.lyrics, existingProject.lyrics) + XCTAssertEqual(result.project.bpm, existingProject.bpm) + XCTAssertEqual(result.project.key, ManualAutoValue(mode: .auto, value: "A")) + XCTAssertEqual(result.project.sections, existingProject.sections) + XCTAssertEqual(result.project.instruments, existingProject.instruments) + XCTAssertEqual(result.project.userLocks, existingProject.userLocks) + XCTAssertEqual(result.changedScopes, [.musicalParameters]) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e442b2..a779303 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,6 +46,9 @@ manual structure, arrangement, musical-parameter and production choices. When a Song Project uses Discuss mode, the AI Director sends the current project and conversation context to the provider and returns follow-up questions without applying a project update. +All structured AI updates pass through a scope-aware merger. It applies only +the scopes declared by the response, ignores user-locked scopes and preserves +manual control values. ## Prompt Compiler diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index f038c2c..940ecf3 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -27,6 +27,7 @@ SongProject - lyrics - productionDirections[] - productionMode: manual | auto +- userLocks[] - sunoOutput - versions[] - createdAt @@ -76,6 +77,11 @@ Discuss mode behavior decision is applied. - A discussion response does not itself modify the Song Project. +User-lock behavior +- userLocks records update scopes explicitly locked by the user. +- AI updates for locked scopes are ignored; every other accepted AI update must + still preserve Manual/Auto choices. + InstrumentPlacement - sectionId (optional) - startTime (optional) diff --git a/docs/TASKS.md b/docs/TASKS.md index e594b67..46da6e2 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -62,7 +62,7 @@ requirement is missing and blocks implementation, record it in - [x] Implement Auto mode for structure, arrangement, BPM/key/maqam and production decisions. - [x] Implement optional Discuss mode. -- [ ] Enforce user-lock/manual-value precedence over AI output. +- [x] Enforce user-lock/manual-value precedence over AI output. - [ ] Add error, retry, cancellation and rate-limit handling. ## Phase 5 --- Arabic Lyrics Processing