Implement optional discussion mode
This commit is contained in:
@@ -38,6 +38,11 @@ struct ProjectInspectorView: View {
|
|||||||
TextField("Title", text: $project.title)
|
TextField("Title", text: $project.title)
|
||||||
TextField("Idea", text: $project.idea, axis: .vertical)
|
TextField("Idea", text: $project.idea, axis: .vertical)
|
||||||
.lineLimit(2...4)
|
.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 seconds", text: durationSecondsBinding)
|
||||||
TextField("Duration note", text: durationDescriptionBinding)
|
TextField("Duration note", text: durationDescriptionBinding)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ public protocol OpenAIClientAdapter: Sendable {
|
|||||||
|
|
||||||
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult
|
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult
|
||||||
|
|
||||||
|
func makeSongProjectDiscussionRequest(
|
||||||
|
_ request: SongProjectDiscussionRequest,
|
||||||
|
configuration: OpenAIClientConfiguration
|
||||||
|
) throws -> OpenAIClientRequest
|
||||||
|
|
||||||
|
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult
|
||||||
|
|
||||||
func makeLyricsRevisionRequest(
|
func makeLyricsRevisionRequest(
|
||||||
_ request: LyricsRevisionRequest,
|
_ request: LyricsRevisionRequest,
|
||||||
configuration: OpenAIClientConfiguration
|
configuration: OpenAIClientConfiguration
|
||||||
@@ -99,6 +106,12 @@ public final class OpenAIClient: AIService, Sendable {
|
|||||||
return try adapter.decodeProjectGenerationResult(from: data)
|
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 {
|
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
|
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
|
||||||
let data = try await perform(clientRequest)
|
let data = try await perform(clientRequest)
|
||||||
|
|||||||
@@ -2,10 +2,21 @@ import Foundation
|
|||||||
|
|
||||||
public protocol AIService: Sendable {
|
public protocol AIService: Sendable {
|
||||||
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult
|
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 reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult
|
||||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult
|
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 struct AIConversationMessage: Equatable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public var role: AIConversationRole
|
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 struct LyricsRevisionRequest: Equatable, Sendable {
|
||||||
public var context: AIRequestContext
|
public var context: AIRequestContext
|
||||||
public var project: SongProject
|
public var project: SongProject
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
|
|||||||
try await baseService.generateSongProject(from: requestWithInjectedRules(request))
|
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 {
|
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
|
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
|
||||||
}
|
}
|
||||||
@@ -59,6 +63,12 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
|
|||||||
return request
|
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 {
|
private func requestWithInjectedRules(_ request: LyricsRevisionRequest) throws -> LyricsRevisionRequest {
|
||||||
var request = request
|
var request = request
|
||||||
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
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?"])
|
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 {
|
func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws {
|
||||||
let service = MockAIService()
|
let service = MockAIService()
|
||||||
let project = SongProject(title: "Lyric Project", idea: "Improve words")
|
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 {
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
LyricsRevisionResult(
|
LyricsRevisionResult(
|
||||||
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
|
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ final class ApplicationRuleInjectionTests: XCTestCase {
|
|||||||
context: AIRequestContext(userInstruction: "Generate.")
|
context: AIRequestContext(userInstruction: "Generate.")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_ = try await service.discussSongProject(
|
||||||
|
from: SongProjectDiscussionRequest(
|
||||||
|
context: AIRequestContext(userInstruction: "Discuss."),
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
)
|
||||||
_ = try await service.reviseLyrics(
|
_ = try await service.reviseLyrics(
|
||||||
from: LyricsRevisionRequest(
|
from: LyricsRevisionRequest(
|
||||||
context: AIRequestContext(userInstruction: "Improve."),
|
context: AIRequestContext(userInstruction: "Improve."),
|
||||||
@@ -37,6 +43,7 @@ final class ApplicationRuleInjectionTests: XCTestCase {
|
|||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
recordedRuleContents,
|
recordedRuleContents,
|
||||||
[
|
[
|
||||||
|
"Use private product rules.",
|
||||||
"Use private product rules.",
|
"Use private product rules.",
|
||||||
"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 {
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
contexts.append(request.context)
|
contexts.append(request.context)
|
||||||
return LyricsRevisionResult(lyrics: request.sourceLyrics)
|
return LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||||
|
|||||||
@@ -68,6 +68,34 @@ final class OpenAIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(recordedURL, overrideURL)
|
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 {
|
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
|
||||||
let transport = RecordingOpenAITransport(
|
let transport = RecordingOpenAITransport(
|
||||||
statusCode: 200,
|
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(
|
func makeLyricsRevisionRequest(
|
||||||
_ request: LyricsRevisionRequest,
|
_ request: LyricsRevisionRequest,
|
||||||
configuration: OpenAIClientConfiguration
|
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
|
For automatic decisions, the AI Director requests only enabled automatic
|
||||||
scopes and merges the response into the current project while preserving
|
scopes and merges the response into the current project while preserving
|
||||||
manual structure, arrangement, musical-parameter and production choices.
|
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
|
## Prompt Compiler
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ Auto-decision behavior
|
|||||||
- The AI may update production directions only when productionMode is auto.
|
- The AI may update production directions only when productionMode is auto.
|
||||||
- Manual values remain authoritative when automatic decisions are applied.
|
- 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
|
InstrumentPlacement
|
||||||
- sectionId (optional)
|
- sectionId (optional)
|
||||||
- startTime (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 existing lyrics → correction/improvement flow.
|
||||||
- [x] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
- [x] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
||||||
and production decisions.
|
and production decisions.
|
||||||
- [ ] Implement optional Discuss mode.
|
- [x] Implement optional Discuss mode.
|
||||||
- [ ] Enforce user-lock/manual-value precedence over AI output.
|
- [ ] Enforce user-lock/manual-value precedence over AI output.
|
||||||
- [ ] Add error, retry, cancellation and rate-limit handling.
|
- [ ] Add error, retry, cancellation and rate-limit handling.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user