Implement optional discussion mode
This commit is contained in:
@@ -38,6 +38,11 @@ struct ProjectInspectorView: View {
|
||||
TextField("Title", text: $project.title)
|
||||
TextField("Idea", text: $project.idea, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
Picker("AI mode", selection: $project.conversationMode) {
|
||||
Text("Auto").tag(ConversationMode.auto)
|
||||
Text("Discuss").tag(ConversationMode.discuss)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
TextField("Duration seconds", text: durationSecondsBinding)
|
||||
TextField("Duration note", text: durationDescriptionBinding)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ public protocol OpenAIClientAdapter: Sendable {
|
||||
|
||||
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult
|
||||
|
||||
func makeSongProjectDiscussionRequest(
|
||||
_ request: SongProjectDiscussionRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest
|
||||
|
||||
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult
|
||||
|
||||
func makeLyricsRevisionRequest(
|
||||
_ request: LyricsRevisionRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
@@ -99,6 +106,12 @@ public final class OpenAIClient: AIService, Sendable {
|
||||
return try adapter.decodeProjectGenerationResult(from: data)
|
||||
}
|
||||
|
||||
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||
let clientRequest = try adapter.makeSongProjectDiscussionRequest(request, configuration: configuration)
|
||||
let data = try await perform(clientRequest)
|
||||
return try adapter.decodeSongProjectDiscussionResult(from: data)
|
||||
}
|
||||
|
||||
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
|
||||
let data = try await perform(clientRequest)
|
||||
|
||||
@@ -2,10 +2,21 @@ import Foundation
|
||||
|
||||
public protocol AIService: Sendable {
|
||||
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult
|
||||
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult
|
||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult
|
||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult
|
||||
}
|
||||
|
||||
public extension AIService {
|
||||
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||
throw AIServiceCapabilityError.discussionNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIServiceCapabilityError: Error, Equatable, Sendable {
|
||||
case discussionNotSupported
|
||||
}
|
||||
|
||||
public struct AIConversationMessage: Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var role: AIConversationRole
|
||||
@@ -82,6 +93,26 @@ public struct SongProjectGenerationResult: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongProjectDiscussionRequest: Equatable, Sendable {
|
||||
public var context: AIRequestContext
|
||||
public var project: SongProject
|
||||
|
||||
public init(context: AIRequestContext, project: SongProject) {
|
||||
self.context = context
|
||||
self.project = project
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongProjectDiscussionResult: Equatable, Sendable {
|
||||
public var questions: [String]
|
||||
public var notes: [String]
|
||||
|
||||
public init(questions: [String], notes: [String] = []) {
|
||||
self.questions = questions
|
||||
self.notes = notes
|
||||
}
|
||||
}
|
||||
|
||||
public struct LyricsRevisionRequest: Equatable, Sendable {
|
||||
public var context: AIRequestContext
|
||||
public var project: SongProject
|
||||
|
||||
@@ -45,6 +45,10 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
|
||||
try await baseService.generateSongProject(from: requestWithInjectedRules(request))
|
||||
}
|
||||
|
||||
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||
try await baseService.discussSongProject(from: requestWithInjectedRules(request))
|
||||
}
|
||||
|
||||
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
|
||||
}
|
||||
@@ -59,6 +63,12 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
|
||||
return request
|
||||
}
|
||||
|
||||
private func requestWithInjectedRules(_ request: SongProjectDiscussionRequest) throws -> SongProjectDiscussionRequest {
|
||||
var request = request
|
||||
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
||||
return request
|
||||
}
|
||||
|
||||
private func requestWithInjectedRules(_ request: LyricsRevisionRequest) throws -> LyricsRevisionRequest {
|
||||
var request = request
|
||||
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
public final class SongProjectDiscussionDirector: Sendable {
|
||||
private let aiService: any AIService
|
||||
|
||||
public init(aiService: any AIService) {
|
||||
self.aiService = aiService
|
||||
}
|
||||
|
||||
public func discuss(
|
||||
project: SongProject,
|
||||
userMessage: String,
|
||||
conversation: [AIConversationMessage] = [],
|
||||
localeIdentifier: String? = nil
|
||||
) async throws -> SongProjectDiscussionResult {
|
||||
guard project.conversationMode == .discuss else {
|
||||
throw SongProjectDiscussionDirectorError.discussionModeNotEnabled
|
||||
}
|
||||
|
||||
let trimmedMessage = userMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedMessage.isEmpty else {
|
||||
throw SongProjectDiscussionDirectorError.emptyUserMessage
|
||||
}
|
||||
|
||||
let result = try await aiService.discussSongProject(
|
||||
from: SongProjectDiscussionRequest(
|
||||
context: AIRequestContext(
|
||||
userInstruction: trimmedMessage,
|
||||
conversation: conversation,
|
||||
localeIdentifier: localeIdentifier
|
||||
),
|
||||
project: project
|
||||
)
|
||||
)
|
||||
|
||||
return SongProjectDiscussionResult(
|
||||
questions: result.questions
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty },
|
||||
notes: result.notes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum SongProjectDiscussionDirectorError: Error, Equatable, Sendable {
|
||||
case discussionModeNotEnabled
|
||||
case emptyUserMessage
|
||||
}
|
||||
@@ -24,6 +24,24 @@ final class AIServiceTests: XCTestCase {
|
||||
XCTAssertEqual(result.followUpQuestions, ["Should the chorus be bigger?"])
|
||||
}
|
||||
|
||||
func testProviderIndependentServiceReturnsDiscussionQuestionsWithoutProjectUpdate() async throws {
|
||||
let service = MockAIService()
|
||||
let project = SongProject(
|
||||
title: "Discussion Project",
|
||||
idea: "Plan the chorus",
|
||||
conversationMode: .discuss
|
||||
)
|
||||
let request = SongProjectDiscussionRequest(
|
||||
context: AIRequestContext(userInstruction: "Ask what is still missing."),
|
||||
project: project
|
||||
)
|
||||
|
||||
let result = try await service.discussSongProject(from: request)
|
||||
|
||||
XCTAssertEqual(result.questions, ["Which vocal delivery should lead the chorus?"])
|
||||
XCTAssertEqual(result.notes, ["Discussion only; no project update was proposed."])
|
||||
}
|
||||
|
||||
func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws {
|
||||
let service = MockAIService()
|
||||
let project = SongProject(title: "Lyric Project", idea: "Improve words")
|
||||
@@ -71,6 +89,13 @@ private struct MockAIService: AIService {
|
||||
)
|
||||
}
|
||||
|
||||
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||
SongProjectDiscussionResult(
|
||||
questions: ["Which vocal delivery should lead the chorus?"],
|
||||
notes: ["Discussion only; no project update was proposed."]
|
||||
)
|
||||
}
|
||||
|
||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
LyricsRevisionResult(
|
||||
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
|
||||
|
||||
@@ -17,6 +17,12 @@ final class ApplicationRuleInjectionTests: XCTestCase {
|
||||
context: AIRequestContext(userInstruction: "Generate.")
|
||||
)
|
||||
)
|
||||
_ = try await service.discussSongProject(
|
||||
from: SongProjectDiscussionRequest(
|
||||
context: AIRequestContext(userInstruction: "Discuss."),
|
||||
project: project
|
||||
)
|
||||
)
|
||||
_ = try await service.reviseLyrics(
|
||||
from: LyricsRevisionRequest(
|
||||
context: AIRequestContext(userInstruction: "Improve."),
|
||||
@@ -37,6 +43,7 @@ final class ApplicationRuleInjectionTests: XCTestCase {
|
||||
XCTAssertEqual(
|
||||
recordedRuleContents,
|
||||
[
|
||||
"Use private product rules.",
|
||||
"Use private product rules.",
|
||||
"Use private product rules.",
|
||||
"Use private product rules."
|
||||
@@ -90,6 +97,11 @@ private actor RecordingAIService: AIService {
|
||||
)
|
||||
}
|
||||
|
||||
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||
contexts.append(request.context)
|
||||
return SongProjectDiscussionResult(questions: ["Which direction should we take?"])
|
||||
}
|
||||
|
||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
contexts.append(request.context)
|
||||
return LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||
|
||||
@@ -68,6 +68,34 @@ final class OpenAIClientTests: XCTestCase {
|
||||
XCTAssertEqual(recordedURL, overrideURL)
|
||||
}
|
||||
|
||||
func testDiscussSongProjectUsesAdapterAndReturnsQuestions() async throws {
|
||||
let transport = RecordingOpenAITransport(
|
||||
statusCode: 200,
|
||||
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Should the chorus be intimate or anthemic?"))
|
||||
)
|
||||
let configuration = OpenAIClientConfiguration(
|
||||
apiKey: "test-api-key",
|
||||
endpointURL: URL(string: "https://api.example.test/v1/configured")!,
|
||||
model: "configured-model"
|
||||
)
|
||||
let client = OpenAIClient(
|
||||
configuration: configuration,
|
||||
adapter: MockOpenAIClientAdapter(),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
let result = try await client.discussSongProject(
|
||||
from: SongProjectDiscussionRequest(
|
||||
context: AIRequestContext(userInstruction: "Ask before deciding."),
|
||||
project: SongProject(title: "Discussion", idea: "Plan this", conversationMode: .discuss)
|
||||
)
|
||||
)
|
||||
let requestBodyString = await transport.requestBodyString
|
||||
|
||||
XCTAssertEqual(result.questions, ["Should the chorus be intimate or anthemic?"])
|
||||
XCTAssertEqual(requestBodyString, "discuss|configured-model|Ask before deciding.")
|
||||
}
|
||||
|
||||
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
|
||||
let transport = RecordingOpenAITransport(
|
||||
statusCode: 200,
|
||||
@@ -175,6 +203,21 @@ private struct MockOpenAIClientAdapter: OpenAIClientAdapter {
|
||||
)
|
||||
}
|
||||
|
||||
func makeSongProjectDiscussionRequest(
|
||||
_ request: SongProjectDiscussionRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest {
|
||||
OpenAIClientRequest(
|
||||
url: overrideURL,
|
||||
body: Data("discuss|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
|
||||
)
|
||||
}
|
||||
|
||||
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult {
|
||||
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
|
||||
return SongProjectDiscussionResult(questions: [response.text])
|
||||
}
|
||||
|
||||
func makeLyricsRevisionRequest(
|
||||
_ request: LyricsRevisionRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class SongProjectDiscussionDirectorTests: XCTestCase {
|
||||
func testDiscussBuildsARequestAndReturnsTrimmedQuestionsWithoutChangingProject() async throws {
|
||||
let project = SongProject(
|
||||
id: "discussion-project",
|
||||
title: "Discussion Project",
|
||||
idea: "Plan a cinematic chorus",
|
||||
conversationMode: .discuss,
|
||||
createdAt: Date(timeIntervalSince1970: 10),
|
||||
updatedAt: Date(timeIntervalSince1970: 20)
|
||||
)
|
||||
let aiService = RecordingDiscussionAIService(
|
||||
result: SongProjectDiscussionResult(
|
||||
questions: [" Should the chorus use a choir? ", " "],
|
||||
notes: ["Awaiting the user's choice."]
|
||||
)
|
||||
)
|
||||
let director = SongProjectDiscussionDirector(aiService: aiService)
|
||||
let conversation = [AIConversationMessage(role: .assistant, content: "What mood should lead?")]
|
||||
|
||||
let result = try await director.discuss(
|
||||
project: project,
|
||||
userMessage: " Keep the verses intimate. ",
|
||||
conversation: conversation,
|
||||
localeIdentifier: "en_US"
|
||||
)
|
||||
let request = await aiService.recordedRequests.first
|
||||
|
||||
XCTAssertEqual(request?.context.userInstruction, "Keep the verses intimate.")
|
||||
XCTAssertEqual(request?.context.conversation, conversation)
|
||||
XCTAssertEqual(request?.context.localeIdentifier, "en_US")
|
||||
XCTAssertEqual(request?.project, project)
|
||||
XCTAssertEqual(result.questions, ["Should the chorus use a choir?"])
|
||||
XCTAssertEqual(result.notes, ["Awaiting the user's choice."])
|
||||
}
|
||||
|
||||
func testDiscussRejectsAutoModeAndBlankMessagesBeforeCallingAI() async throws {
|
||||
let aiService = RecordingDiscussionAIService(
|
||||
result: SongProjectDiscussionResult(questions: ["Unused"])
|
||||
)
|
||||
let director = SongProjectDiscussionDirector(aiService: aiService)
|
||||
|
||||
do {
|
||||
_ = try await director.discuss(
|
||||
project: SongProject(title: "Auto", idea: "Auto", conversationMode: .auto),
|
||||
userMessage: "Ask a question."
|
||||
)
|
||||
XCTFail("Expected discussion mode validation to fail.")
|
||||
} catch let error as SongProjectDiscussionDirectorError {
|
||||
XCTAssertEqual(error, .discussionModeNotEnabled)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await director.discuss(
|
||||
project: SongProject(title: "Discuss", idea: "Discuss", conversationMode: .discuss),
|
||||
userMessage: " \n "
|
||||
)
|
||||
XCTFail("Expected blank message validation to fail.")
|
||||
} catch let error as SongProjectDiscussionDirectorError {
|
||||
XCTAssertEqual(error, .emptyUserMessage)
|
||||
}
|
||||
|
||||
let requestCount = await aiService.recordedRequests.count
|
||||
XCTAssertEqual(requestCount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecordingDiscussionAIService: AIService {
|
||||
private(set) var recordedRequests: [SongProjectDiscussionRequest] = []
|
||||
private let result: SongProjectDiscussionResult
|
||||
|
||||
init(result: SongProjectDiscussionResult) {
|
||||
self.result = result
|
||||
}
|
||||
|
||||
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||
SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused"))
|
||||
}
|
||||
|
||||
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||
recordedRequests.append(request)
|
||||
return result
|
||||
}
|
||||
|
||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||
}
|
||||
|
||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||
SongProjectUpdateResult(project: request.project)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ data wherever possible, not treated as an unstructured chat transcript.
|
||||
For automatic decisions, the AI Director requests only enabled automatic
|
||||
scopes and merges the response into the current project while preserving
|
||||
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.
|
||||
|
||||
## Prompt Compiler
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ Auto-decision behavior
|
||||
- The AI may update production directions only when productionMode is auto.
|
||||
- Manual values remain authoritative when automatic decisions are applied.
|
||||
|
||||
Discuss mode behavior
|
||||
- When conversationMode is discuss, the AI receives the current Song Project
|
||||
and conversation context, then returns follow-up questions before a project
|
||||
decision is applied.
|
||||
- A discussion response does not itself modify the Song Project.
|
||||
|
||||
InstrumentPlacement
|
||||
- sectionId (optional)
|
||||
- startTime (optional)
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ requirement is missing and blocks implementation, record it in
|
||||
- [x] Implement existing lyrics → correction/improvement flow.
|
||||
- [x] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
||||
and production decisions.
|
||||
- [ ] Implement optional Discuss mode.
|
||||
- [x] Implement optional Discuss mode.
|
||||
- [ ] Enforce user-lock/manual-value precedence over AI output.
|
||||
- [ ] Add error, retry, cancellation and rate-limit handling.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user