Implement existing lyrics revision flow
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public final class ExistingLyricsRevisionDirector: Sendable {
|
||||||
|
private let aiService: any AIService
|
||||||
|
|
||||||
|
public init(aiService: any AIService) {
|
||||||
|
self.aiService = aiService
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reviseExistingLyrics(
|
||||||
|
in project: SongProject,
|
||||||
|
sourceLyrics: Lyrics? = nil,
|
||||||
|
instruction: String,
|
||||||
|
mode: LyricsRevisionMode,
|
||||||
|
conversation: [AIConversationMessage] = [],
|
||||||
|
localeIdentifier: String? = nil,
|
||||||
|
revisedAt: Date = Date()
|
||||||
|
) async throws -> SongProjectUpdateResult {
|
||||||
|
guard mode != .writeFromIdea else {
|
||||||
|
throw ExistingLyricsRevisionDirectorError.unsupportedMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedInstruction.isEmpty else {
|
||||||
|
throw ExistingLyricsRevisionDirectorError.emptyInstruction
|
||||||
|
}
|
||||||
|
|
||||||
|
let lyricsToRevise = sourceLyrics ?? project.lyrics
|
||||||
|
guard !lyricsToRevise.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
throw ExistingLyricsRevisionDirectorError.emptySourceLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = LyricsRevisionRequest(
|
||||||
|
context: AIRequestContext(
|
||||||
|
userInstruction: trimmedInstruction,
|
||||||
|
conversation: conversation,
|
||||||
|
localeIdentifier: localeIdentifier
|
||||||
|
),
|
||||||
|
project: project,
|
||||||
|
sourceLyrics: lyricsToRevise,
|
||||||
|
mode: mode
|
||||||
|
)
|
||||||
|
let result = try await aiService.reviseLyrics(from: request)
|
||||||
|
|
||||||
|
var updatedProject = project
|
||||||
|
updatedProject.lyrics = result.lyrics
|
||||||
|
updatedProject.updatedAt = revisedAt
|
||||||
|
|
||||||
|
return SongProjectUpdateResult(
|
||||||
|
project: updatedProject,
|
||||||
|
changedScopes: [.lyrics],
|
||||||
|
notes: result.notes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExistingLyricsRevisionDirectorError: Error, Equatable, Sendable {
|
||||||
|
case emptyInstruction
|
||||||
|
case emptySourceLyrics
|
||||||
|
case unsupportedMode(LyricsRevisionMode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ExistingLyricsRevisionDirectorTests: XCTestCase {
|
||||||
|
func testReviseExistingLyricsBuildsRevisionRequestAndUpdatesProjectLyrics() async throws {
|
||||||
|
let revisedAt = Date(timeIntervalSince1970: 500)
|
||||||
|
let project = SongProject(
|
||||||
|
id: "lyric-project",
|
||||||
|
title: "Lyric Project",
|
||||||
|
idea: "Make the hook stronger",
|
||||||
|
lyrics: Lyrics(text: "Original lines"),
|
||||||
|
createdAt: Date(timeIntervalSince1970: 100),
|
||||||
|
updatedAt: Date(timeIntervalSince1970: 200)
|
||||||
|
)
|
||||||
|
let aiService = RecordingLyricsAIService(
|
||||||
|
revisionResult: LyricsRevisionResult(
|
||||||
|
lyrics: Lyrics(text: "Revised lines"),
|
||||||
|
notes: ["Improved hook phrasing."]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||||
|
let conversation = [
|
||||||
|
AIConversationMessage(role: .user, content: "Keep it direct.")
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = try await director.reviseExistingLyrics(
|
||||||
|
in: project,
|
||||||
|
instruction: " Improve the chorus. ",
|
||||||
|
mode: .improve,
|
||||||
|
conversation: conversation,
|
||||||
|
localeIdentifier: "en_US",
|
||||||
|
revisedAt: revisedAt
|
||||||
|
)
|
||||||
|
let recordedRequest = await aiService.recordedRevisionRequests.first
|
||||||
|
|
||||||
|
XCTAssertEqual(recordedRequest?.context.userInstruction, "Improve the chorus.")
|
||||||
|
XCTAssertEqual(recordedRequest?.context.conversation, conversation)
|
||||||
|
XCTAssertEqual(recordedRequest?.context.localeIdentifier, "en_US")
|
||||||
|
XCTAssertEqual(recordedRequest?.project, project)
|
||||||
|
XCTAssertEqual(recordedRequest?.sourceLyrics, Lyrics(text: "Original lines"))
|
||||||
|
XCTAssertEqual(recordedRequest?.mode, .improve)
|
||||||
|
XCTAssertEqual(result.project.id, project.id)
|
||||||
|
XCTAssertEqual(result.project.createdAt, project.createdAt)
|
||||||
|
XCTAssertEqual(result.project.lyrics, Lyrics(text: "Revised lines"))
|
||||||
|
XCTAssertEqual(result.project.updatedAt, revisedAt)
|
||||||
|
XCTAssertEqual(result.changedScopes, [.lyrics])
|
||||||
|
XCTAssertEqual(result.notes, ["Improved hook phrasing."])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReviseExistingLyricsCanUseExplicitSourceLyricsWithoutMutatingOtherProjectFields() async throws {
|
||||||
|
let project = SongProject(
|
||||||
|
title: "Existing Project",
|
||||||
|
idea: "Complete second verse",
|
||||||
|
genres: [GenreStyle(id: "pop", name: "Pop")],
|
||||||
|
lyrics: Lyrics(text: "Stored project lyrics")
|
||||||
|
)
|
||||||
|
let sourceLyrics = Lyrics(text: "External draft lyrics")
|
||||||
|
let aiService = RecordingLyricsAIService(
|
||||||
|
revisionResult: LyricsRevisionResult(
|
||||||
|
lyrics: Lyrics(text: "Completed external draft")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||||
|
|
||||||
|
let result = try await director.reviseExistingLyrics(
|
||||||
|
in: project,
|
||||||
|
sourceLyrics: sourceLyrics,
|
||||||
|
instruction: "Complete this draft.",
|
||||||
|
mode: .complete,
|
||||||
|
revisedAt: Date(timeIntervalSince1970: 900)
|
||||||
|
)
|
||||||
|
let recordedRequest = await aiService.recordedRevisionRequests.first
|
||||||
|
|
||||||
|
XCTAssertEqual(recordedRequest?.sourceLyrics, sourceLyrics)
|
||||||
|
XCTAssertEqual(result.project.title, project.title)
|
||||||
|
XCTAssertEqual(result.project.idea, project.idea)
|
||||||
|
XCTAssertEqual(result.project.genres, project.genres)
|
||||||
|
XCTAssertEqual(result.project.lyrics.text, "Completed external draft")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReviseExistingLyricsRejectsBlankSourceBeforeCallingAI() async {
|
||||||
|
let aiService = RecordingLyricsAIService(
|
||||||
|
revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused"))
|
||||||
|
)
|
||||||
|
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await director.reviseExistingLyrics(
|
||||||
|
in: SongProject(title: "Blank", idea: "Blank", lyrics: Lyrics(text: " \n ")),
|
||||||
|
instruction: "Improve.",
|
||||||
|
mode: .improve
|
||||||
|
)
|
||||||
|
XCTFail("Expected blank source lyrics to throw.")
|
||||||
|
} catch let error as ExistingLyricsRevisionDirectorError {
|
||||||
|
XCTAssertEqual(error, .emptySourceLyrics)
|
||||||
|
} catch {
|
||||||
|
XCTFail("Expected ExistingLyricsRevisionDirectorError.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestCount = await aiService.recordedRevisionRequests.count
|
||||||
|
XCTAssertEqual(requestCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReviseExistingLyricsRejectsBlankInstructionAndWriteFromIdeaMode() async {
|
||||||
|
let aiService = RecordingLyricsAIService(
|
||||||
|
revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused"))
|
||||||
|
)
|
||||||
|
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||||
|
let project = SongProject(title: "Song", idea: "Idea", lyrics: Lyrics(text: "Draft"))
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await director.reviseExistingLyrics(
|
||||||
|
in: project,
|
||||||
|
instruction: " ",
|
||||||
|
mode: .correct
|
||||||
|
)
|
||||||
|
XCTFail("Expected blank instruction to throw.")
|
||||||
|
} catch let error as ExistingLyricsRevisionDirectorError {
|
||||||
|
XCTAssertEqual(error, .emptyInstruction)
|
||||||
|
} catch {
|
||||||
|
XCTFail("Expected ExistingLyricsRevisionDirectorError.")
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await director.reviseExistingLyrics(
|
||||||
|
in: project,
|
||||||
|
instruction: "Write from idea.",
|
||||||
|
mode: .writeFromIdea
|
||||||
|
)
|
||||||
|
XCTFail("Expected unsupported mode to throw.")
|
||||||
|
} catch let error as ExistingLyricsRevisionDirectorError {
|
||||||
|
XCTAssertEqual(error, .unsupportedMode(.writeFromIdea))
|
||||||
|
} catch {
|
||||||
|
XCTFail("Expected ExistingLyricsRevisionDirectorError.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestCount = await aiService.recordedRevisionRequests.count
|
||||||
|
XCTAssertEqual(requestCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor RecordingLyricsAIService: AIService {
|
||||||
|
private(set) var recordedRevisionRequests: [LyricsRevisionRequest] = []
|
||||||
|
private let revisionResult: LyricsRevisionResult
|
||||||
|
|
||||||
|
init(revisionResult: LyricsRevisionResult) {
|
||||||
|
self.revisionResult = revisionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||||
|
SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
|
recordedRevisionRequests.append(request)
|
||||||
|
return revisionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||||
|
SongProjectUpdateResult(project: request.project)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -58,7 +58,7 @@ requirement is missing and blocks implementation, record it in
|
|||||||
- [x] Define private application-rule injection mechanism.
|
- [x] Define private application-rule injection mechanism.
|
||||||
- [x] Define structured AI response schema for SongProject updates.
|
- [x] Define structured AI response schema for SongProject updates.
|
||||||
- [x] Implement idea → complete project generation.
|
- [x] Implement idea → complete project generation.
|
||||||
- [ ] Implement existing lyrics → correction/improvement flow.
|
- [x] Implement existing lyrics → correction/improvement flow.
|
||||||
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
||||||
and production decisions.
|
and production decisions.
|
||||||
- [ ] Implement optional Discuss mode.
|
- [ ] Implement optional Discuss mode.
|
||||||
|
|||||||
Reference in New Issue
Block a user