diff --git a/Modules/Package.swift b/Modules/Package.swift index 05537222fc35..227794c6ea38 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -20,6 +20,7 @@ let package = Package( .library(name: "WordPressFlux", targets: ["WordPressFlux"]), .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), + .library(name: "WordPressIntelligence", targets: ["WordPressIntelligence"]), .library(name: "WordPressReader", targets: ["WordPressReader"]), .library(name: "WordPressCore", targets: ["WordPressCore"]), .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), @@ -163,6 +164,10 @@ let package = Package( // This package should never have dependencies – it exists to expose protocols implemented in WordPressCore // to UI code, because `wordpress-rs` doesn't work nicely with previews. ]), + .target(name: "WordPressIntelligence", dependencies: [ + "WordPressShared", + .product(name: "SwiftSoup", package: "SwiftSoup"), + ]), .target(name: "WordPressLegacy", dependencies: ["DesignSystem", "WordPressShared"]), .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target( @@ -251,6 +256,7 @@ let package = Package( .testTarget(name: "WordPressSharedObjCTests", dependencies: [.target(name: "WordPressShared"), .target(name: "WordPressTesting")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget(name: "WordPressUIUnitTests", dependencies: [.target(name: "WordPressUI")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget(name: "WordPressCoreTests", dependencies: [.target(name: "WordPressCore")]), + .testTarget(name: "WordPressIntelligenceTests", dependencies: [.target(name: "WordPressIntelligence")]) ] ) diff --git a/Modules/Sources/WordPressIntelligence/IntelligenceService.swift b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift new file mode 100644 index 000000000000..289b59474570 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift @@ -0,0 +1,74 @@ +import Foundation +import FoundationModels + +/// Service for AI-powered content generation and analysis features. +/// +/// This service provides tag suggestions, post summaries, excerpt generation, +/// and other intelligence features using Foundation Models (iOS 26+). +public actor IntelligenceService { + /// Maximum context size for language model sessions (in tokens). + /// + /// A single token corresponds to three or four characters in languages like + /// English, Spanish, or German, and one token per character in languages like + /// Japanese, Chinese, or Korean. In a single session, the sum of all tokens + /// in the instructions, all prompts, and all outputs count toward the context window size. + /// + /// https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models#Consider-context-size-limits-per-session + public static let contextSizeLimit = 4096 + + /// Checks if intelligence features are supported on the current device. + public nonisolated static var isSupported: Bool { + guard #available(iOS 26, *) else { + return false + } + switch SystemLanguageModel.default.availability { + case .available: + return true + case .unavailable(let reason): + switch reason { + case .appleIntelligenceNotEnabled, .modelNotReady: + return true + case .deviceNotEligible: + return false + @unknown default: + return false + } + } + } + + public init() {} + + // MARK: - Public API + + /// Suggests tags for a WordPress post. + @available(iOS 26, *) + public func suggestTags(post: String, siteTags: [String] = [], postTags: [String] = []) async throws -> [String] { + try await TagSuggestion.execute(post: post, siteTags: siteTags, postTags: postTags) + } + + /// Summarizes a WordPress post. + @available(iOS 26, *) + public func summarizePost(content: String) async throws -> String { + try await PostSummary.execute(content: content) + } + + /// Summarizes a support ticket to a short title. + @available(iOS 26, *) + public func summarizeSupportTicket(content: String) async throws -> String { + try await SupportTicketSummary.execute(content: content) + } + + /// Extracts relevant text from post content (removes HTML, limits size). + public nonisolated func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { + Self.extractRelevantText(from: post, ratio: ratio) + } + + // MARK: - Shared Utilities + + /// Extracts relevant text from post content, removing HTML and limiting size. + public nonisolated static func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { + let extract = try? ContentExtractor.extractRelevantText(from: post) + let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio + return String((extract ?? post).prefix(Int(postSizeLimit))) + } +} diff --git a/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift b/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift new file mode 100644 index 000000000000..b9db14d8af91 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift @@ -0,0 +1,40 @@ +import Foundation +import WordPressShared + +/// Target length for generated text. +public enum ContentLength: Int, CaseIterable, Sendable { + case short + case medium + case long + + public var displayName: String { + switch self { + case .short: + AppLocalizedString("generation.length.short", value: "Short", comment: "Generated content length (needs to be short)") + case .medium: + AppLocalizedString("generation.length.medium", value: "Medium", comment: "Generated content length (needs to be short)") + case .long: + AppLocalizedString("generation.length.long", value: "Long", comment: "Generated content length (needs to be short)") + } + } + + public var trackingName: String { + switch self { + case .short: "short" + case .medium: "medium" + case .long: "long" + } + } + + public var promptModifier: String { + "\(wordRange) words" + } + + private var wordRange: String { + switch self { + case .short: "20-40" + case .medium: "50-70" + case .long: "120-180" + } + } +} diff --git a/Modules/Sources/WordPressIntelligence/Parameters/WritingStyle.swift b/Modules/Sources/WordPressIntelligence/Parameters/WritingStyle.swift new file mode 100644 index 000000000000..0d39098730fe --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/Parameters/WritingStyle.swift @@ -0,0 +1,40 @@ +import Foundation +import WordPressShared + +/// Writing style for generated text. +public enum WritingStyle: String, CaseIterable, Sendable { + case engaging + case conversational + case witty + case formal + case professional + + public var displayName: String { + switch self { + case .engaging: + AppLocalizedString("generation.style.engaging", value: "Engaging", comment: "AI generation style") + case .conversational: + AppLocalizedString("generation.style.conversational", value: "Conversational", comment: "AI generation style") + case .witty: + AppLocalizedString("generation.style.witty", value: "Witty", comment: "AI generation style") + case .formal: + AppLocalizedString("generation.style.formal", value: "Formal", comment: "AI generation style") + case .professional: + AppLocalizedString("generation.style.professional", value: "Professional", comment: "AI generation style") + } + } + + var promptModifier: String { + "\(rawValue) (\(promptModifierDetails))" + } + + var promptModifierDetails: String { + switch self { + case .engaging: "engaging and compelling tone" + case .witty: "witty, creative, entertaining" + case .conversational: "friendly and conversational tone" + case .formal: "formal and academic tone" + case .professional: "professional and polished tone" + } + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift new file mode 100644 index 000000000000..371eea02a48a --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift @@ -0,0 +1,98 @@ +import Foundation +import FoundationModels + +/// Excerpt generation for WordPress posts. +/// +/// Generates multiple excerpt variations for blog posts with customizable +/// length and writing style. Supports session-based usage (for UI with continuity) +/// and one-shot generation (for tests and background tasks). +@available(iOS 26, *) +public struct ExcerptGeneration { + public var length: ContentLength + public var style: WritingStyle + public var options: GenerationOptions + + public init( + length: ContentLength, + style: WritingStyle, + options: GenerationOptions = GenerationOptions(temperature: 0.7) + ) { + self.length = length + self.style = style + self.options = options + } + + /// Generates excerpts with this configuration. + public func generate(for content: String) async throws -> [String] { + let content = IntelligenceService.extractRelevantText(from: content) + let response = try await makeSession().respond( + to: makePrompt(content: content), + generating: Result.self, + options: options + ) + return response.content.excerpts + } + + /// Creates a language model session configured for excerpt generation. + public func makeSession() -> LanguageModelSession { + LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: Self.instructions + ) + } + + /// Instructions for the language model session. + public static var instructions: String { + """ + Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. + + Generate excerpts in the same language as the POST_CONTENT. + + **Paramters** + - POST_CONTENT: contents of the post (HTML or plain text) + - GENERATED_CONTENT_LENGTH: the length of the generated content + - GENERATION_STYLE: the writing style to follow + + **Requirements** + - Each excerpt must follow the provided GENERATED_CONTENT_LENGTH and use GENERATION_STYLE + + **Excerpt best practices** + - Follow the best practices for post excerpts esteblished in the WordPress ecosystem + - Include the post's main value proposition + - Use active voice (avoid "is", "are", "was", "were" when possible) + - End with implicit promise of more information + - Do not use ellipsis (...) at the end + - Focus on value, not summary + - Include strategic keywords naturally + - Write independently from the introduction – excerpt shouldn't just duplicate your opening paragraph. While your introduction eases readers into the topic, your excerpt needs to work as standalone copy that makes sense out of context—whether it appears in search results, social media cards, or email newsletters. + """ + } + + + /// Creates a prompt for this excerpt configuration. + public func makePrompt(content: String) -> String { + """ + Generate three different excerpts for the given post and parameters + + GENERATED_CONTENT_LENGTH: \(length.promptModifier) + + GENERATION_STYLE: \(style.promptModifier) + + POST_CONTENT: ''' + \(content) + """ + } + + /// Prompt for generating additional excerpt options. + public static var loadMorePrompt: String { + "Generate additional three options" + } + + // MARK: - Result Type + + @Generable + public struct Result { + @Guide(description: "Three different excerpt variations") + public var excerpts: [String] + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift b/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift new file mode 100644 index 000000000000..5f82bed379fc --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift @@ -0,0 +1,37 @@ +import Foundation +import FoundationModels + +@available(iOS 26, *) +extension IntelligenceService { + /// Post summarization for WordPress content. + /// + /// Generates concise summaries that capture the main points and key information + /// from WordPress post content in the same language as the source. + public enum PostSummary { + static func execute(content: String) async throws -> String { + let content = IntelligenceService.extractRelevantText(from: content, ratio: 0.8) + + let instructions = """ + You are helping a WordPress user understand the content of a post. + Generate a concise summary that captures the main points and key information. + The summary should be clear, informative, and written in a neutral tone. + You MUST generate the summary in the same language as the post content. + + Do not include anything other than the summary in the response. + """ + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let prompt = """ + Summarize the following post: + + \(content) + """ + + return try await session.respond(to: prompt).content + } + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummary.swift b/Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummary.swift new file mode 100644 index 000000000000..15e15fcf3405 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummary.swift @@ -0,0 +1,46 @@ +import Foundation +import FoundationModels + +@available(iOS 26, *) +extension IntelligenceService { + /// Support ticket summarization. + /// + /// Generates short, concise titles (fewer than 10 words) for support + /// conversations based on the opening message. + public enum SupportTicketSummary { + static func execute(content: String) async throws -> String { + let instructions = """ + You are helping a user by summarizing their support request down to a single sentence + with fewer than 10 words. + + The summary should be clear, informative, and written in a neutral tone. + You MUST generate the summary in the same language as the support request. + + Do not include anything other than the summary in the response. + """ + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let prompt = """ + Give me an appropriate conversation title for the following opening message of the conversation: + + \(content) + """ + + return try await session.respond( + to: prompt, + generating: Result.self, + options: GenerationOptions(temperature: 1.0) + ).content.title + } + + @Generable + struct Result { + @Guide(description: "The conversation title") + var title: String + } + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/TagSuggestion.swift b/Modules/Sources/WordPressIntelligence/UseCases/TagSuggestion.swift new file mode 100644 index 000000000000..5aedeb890c07 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/TagSuggestion.swift @@ -0,0 +1,86 @@ +import Foundation +import FoundationModels +import WordPressShared + +@available(iOS 26, *) +extension IntelligenceService { + /// Tag suggestion for WordPress posts. + /// + /// Generates relevant tags based on post content and existing site tags, + /// matching the language and formatting pattern of existing tags. + public enum TagSuggestion { + static func execute(post: String, siteTags: [String], postTags: [String]) async throws -> [String] { + let startTime = CFAbsoluteTimeGetCurrent() + + // Limit siteTags and content size to respect context window + let siteTags = siteTags.prefix(50) + let post = IntelligenceService.extractRelevantText(from: post) + + try Task.checkCancellation() + + let instructions = """ + You are helping a WordPress user add tags to a post or a page. + + **Parameters** + - POST_CONTENT: contents of the post (HTML or plain text) + - SITE_TAGS: case-sensitive comma-separated list of the existing tags used elsewhere on the site (not always relevant to the post) + - EXISTING_POST_TAGS: tags already added to the post + + **Steps** + - 1. Identify the specific formatting pattern used (e.g., lowercase with underscores, capitalized words with spaces, etc) + - 2. Identify the language used in SITE_TAGS and POST_CONTENT + - 3. Generate a list of ten most relevant suggested tags based on POST_CONTENT and SITE_TAGS relevant to the content. + + **Requirements** + - You MUST generate tags in the same language as SITE_TAGS and POST_CONTENT + - Tags MUST match the formatting pattern and language of existing tags + - Do not include any tags from EXISTING_POST_TAGS + - If there are no relevant suggestions, returns an empty list + - Do not produce any output other than the final list of tag + """ + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let prompt = """ + Suggest up to ten tags for a post. + + POST_CONTENT: ''' + \(post) + ''' + + SITE_TAGS: '\(siteTags.joined(separator: ", "))' + + EXISTING_POST_TAGS: '\(postTags.joined(separator: ", "))' + """ + + let response = try await session.respond( + to: prompt, + generating: Result.self, + options: GenerationOptions(temperature: 0.2) + ) + + WPLogInfo("IntelligenceService.TagSuggestion executed in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") + + let existingPostTags = Set(postTags) + return response.content.tags + .deduplicated() + .filter { !existingPostTags.contains($0) } + } + + @Generable + struct Result { + @Guide(description: "Newly generated tags following the identified format") + var tags: [String] + } + } +} + +private extension Array where Element: Hashable { + func deduplicated() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceUtilities.swift b/Modules/Sources/WordPressIntelligence/Utilities/ContentExtractor.swift similarity index 98% rename from Modules/Sources/WordPressShared/Intelligence/IntelligenceUtilities.swift rename to Modules/Sources/WordPressIntelligence/Utilities/ContentExtractor.swift index 47406b0ed1e9..e6c0c1bf522b 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceUtilities.swift +++ b/Modules/Sources/WordPressIntelligence/Utilities/ContentExtractor.swift @@ -1,7 +1,7 @@ import Foundation import SwiftSoup -public struct IntelligenceUtilities { +public enum ContentExtractor { /// Extracts semantically meaningful content from HTML for LLM processing. /// /// Optimized for language models by: diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift deleted file mode 100644 index 66f386c49c9e..000000000000 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation -import FoundationModels - -@available(iOS 26, *) -public actor IntelligenceService { - /// A single token corresponds to three or four characters in languages like - /// English, Spanish, or German, and one token per character in languages like - /// Japanese, Chinese, or Korean. In a single session, the sum of all tokens - /// in the instructions, all prompts, and all outputs count toward the context window size. - /// - /// https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models#Consider-context-size-limits-per-session - static let contextSizeLimit = 4096 - - public nonisolated static var isSupported: Bool { - LanguageModelHelper.isSupported - } - - public init() {} - - /// Suggests tags for a WordPress post. - /// - /// - Parameters: - /// - post: The content of the WordPress post. - /// - siteTags: An array of existing tags used elsewhere on the site. - /// - postTags: An array of tags already assigned to the post. - /// - /// - Returns: An array of suggested tags. - public func suggestTags(post: String, siteTags: [String] = [], postTags: [String] = []) async throws -> [String] { - let startTime = CFAbsoluteTimeGetCurrent() - - // We have to be mindful of the content size limit, so we - // only support a subset of tags, preamptively remove Gutenberg tags - // from the content, and limit the content size. - - // A maximum of 500 characters assuming 10 characters per - let siteTags = siteTags.prefix(50) - let post = extractRelevantText(from: post) - - try Task.checkCancellation() - - // Notes: - // - It was critical to add "case-sensitive" as otherwise it would ignore - // case sensitivity and pick the wrong output format. - // - The lowered temperature helped improved the accuracy. - // - `useCase: .contentTagging` is not recommended for arbitraty hashtags - - let instructions = """ - You are helping a WordPress user add tags to a post or a page. - - **Parameters** - - POST_CONTENT: contents of the post (HTML or plain text) - - SITE_TAGS: case-sensitive comma-separated list of the existing tags used elsewhere on the site (not always relevant to the post) - - EXISTING_POST_TAGS: tags already added to the post - - **Steps** - - 1. Identify the specific formatting pattern used (e.g., lowercase with underscores, capitalized words with spaces, etc) - - 2. Generate a list of ten most relevant suggested tags based on POST_CONTENT and SITE_TAGS relevant to the content. - - **Requirements** - - Do not include any tags from EXISTING_POST_TAGS - - If there are no relevant suggestions, returns an empty list - - Do not produce any output other than the final list of tag - """ - - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: instructions - ) - - let prompt = """ - Suggest up to ten tags for a post. - - POST_CONTENT: ''' - \(post) - ''' - - SITE_TAGS: '\(siteTags.joined(separator: ", "))' - - EXISTING_POST_TAGS: '\(postTags.joined(separator: ", "))' - """ - - let response = try await session.respond( - to: prompt, - generating: SuggestedTagsResult.self, - options: GenerationOptions(temperature: 0.2) - ) - - WPLogInfo("IntelligenceService.suggestTags executed in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") - - let existingPostTags = Set(postTags) - return response.content.tags - .deduplicated() - .filter { !existingPostTags.contains($0) } - } - - /// Summarizes a WordPress post. - /// - /// - Parameter content: The content of the WordPress post (HTML or plain text). - /// - Returns: An async stream of partial summaries as they are generated. - public func summarizePost(content: String) -> LanguageModelSession.ResponseStream { - let content = extractRelevantText(from: content, ratio: 0.8) - - let instructions = """ - You are helping a WordPress user understand the content of a post. - Generate a concise summary that captures the main points and key information. - The summary should be clear, informative, and written in a neutral tone. - - Do not include anything other than the summary in the response. - """ - - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: instructions - ) - - let prompt = """ - Summarize the following post: - - \(content) - """ - - return session.streamResponse(to: prompt) - } - - public func summarizeSupportTicket(content: String) async throws -> String { - let instructions = """ - You are helping a user by summarizing their support request down to a single sentence - with fewer than 10 words. - - The summary should be clear, informative, and written in a neutral tone. - - Do not include anything other than the summary in the response. - """ - - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: instructions - ) - - let prompt = """ - Give me an appropriate conversation title for the following opening message of the conversation: - - \(content) - """ - - return try await session.respond( - to: prompt, - generating: SuggestedConversationTitle.self, - options: GenerationOptions(temperature: 1.0) - ).content.title - } - - public nonisolated func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { - let extract = try? IntelligenceUtilities.extractRelevantText(from: post) - let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio - return String((extract ?? post).prefix(Int(postSizeLimit))) - } -} - -private extension Array where Element: Hashable { - func deduplicated() -> [Element] { - var seen = Set() - return filter { seen.insert($0).inserted } - } -} - -@available(iOS 26, *) -@Generable -private struct SuggestedTagsResult { - @Guide(description: "Newly generated tags following the identified format") - var tags: [String] -} - -@available(iOS 26, *) -@Generable -private struct SuggestedConversationTitle { - @Guide(description: "The conversation title") - var title: String -} diff --git a/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift b/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift deleted file mode 100644 index 663a2cb3c080..000000000000 --- a/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation -import FoundationModels - -public enum LanguageModelHelper { - public static var isSupported: Bool { - guard #available(iOS 26, *) else { return false } - switch SystemLanguageModel.default.availability { - case .available: - return true - case .unavailable(let reason): - switch reason { - case .appleIntelligenceNotEnabled, .modelNotReady: - return true - case .deviceNotEligible: - return false - @unknown default: - return false - } - } - } - - public static var generateExcerptInstructions: String { - """ - Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. - - **Paramters** - - POST_CONTENT: contents of the post (HTML or plain text) - - GENERATED_CONTENT_LENGTH: the length of the generated content - - GENERATION_STYLE: the writing style to follow - - **Requirements** - - Each excerpt must follow the provided GENERATED_CONTENT_LENGTH and use GENERATION_STYLE - - **Excerpt best practices** - - Follow the best practices for post excerpts esteblished in the WordPress ecosystem - - Include the post's main value proposition - - Use active voice (avoid "is", "are", "was", "were" when possible) - - End with implicit promise of more information - - Do not use ellipsis (...) at the end - - Focus on value, not summary - - Include strategic keywords naturally - - Write independently from the introduction – excerpt shouldn't just duplicate your opening paragraph. While your introduction eases readers into the topic, your excerpt needs to work as standalone copy that makes sense out of context—whether it appears in search results, social media cards, or email newsletters. - """ - } - - public static func makeGenerateExcerptPrompt( - content: String, - length: GeneratedContentLength, - style: GenerationStyle - ) -> String { - """ - Generate three different excerpts for the given post and parameters - - GENERATED_CONTENT_LENGTH: \(length.promptModifier) - - GENERATION_STYLE: \(style.promptModifier) - - POST_CONTENT: ''' - \(content) - """ - } - - public static var generateMoreOptionsPrompt: String { - "Generate additional three options" - } -} - -public enum GenerationStyle: String, CaseIterable, RawRepresentable { - case engaging - case conversational - case witty - case formal - case professional - - public var displayName: String { - switch self { - case .engaging: - NSLocalizedString("generation.style.engaging", value: "Engaging", comment: "AI generation style") - case .conversational: - NSLocalizedString("generation.style.conversational", value: "Conversational", comment: "AI generation style") - case .witty: - NSLocalizedString("generation.style.witty", value: "Witty", comment: "AI generation style") - case .formal: - NSLocalizedString("generation.style.formal", value: "Formal", comment: "AI generation style") - case .professional: - NSLocalizedString("generation.style.professional", value: "Professional", comment: "AI generation style") - } - } - - public var promptModifier: String { - "\(rawValue) (\(promptModifierDetails))" - } - - var promptModifierDetails: String { - switch self { - case .engaging: "engaging and compelling tone" - case .witty: "witty, creative, entertaining" - case .conversational: "friendly and conversational tone" - case .formal: "formal and academic tone" - case .professional: "professional and polished tone" - } - } -} - -public enum GeneratedContentLength: Int, CaseIterable, RawRepresentable { - case short - case medium - case long - - public var displayName: String { - switch self { - case .short: - NSLocalizedString("generation.length.short", value: "Short", comment: "Generated content length (needs to be short)") - case .medium: - NSLocalizedString("generation.length.medium", value: "Medium", comment: "Generated content length (needs to be short)") - case .long: - NSLocalizedString("generation.length.long", value: "Long", comment: "Generated content length (needs to be short)") - } - } - - public var trackingName: String { name } - - public var promptModifier: String { - "\(wordRange) words" - } - - private var name: String { - switch self { - case .short: "short" - case .medium: "medium" - case .long: "long" - } - } - - private var wordRange: String { - switch self { - case .short: "20-40" - case .medium: "50-70" - case .long: "120-180" - } - } -} diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceUtilitiesTests.swift b/Modules/Tests/WordPressIntelligenceTests/ContentExtractorTests.swift similarity index 95% rename from Modules/Tests/WordPressSharedTests/IntelligenceUtilitiesTests.swift rename to Modules/Tests/WordPressIntelligenceTests/ContentExtractorTests.swift index 657644c0e0fd..ccec0ddb38db 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceUtilitiesTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/ContentExtractorTests.swift @@ -1,9 +1,9 @@ import Testing -@testable import WordPressShared +@testable import WordPressIntelligence -struct IntelligenceUtilitiesTests { +struct ContentExtractorTests { @Test func extractRelevantText() throws { - let text = try IntelligenceUtilities.extractRelevantText(from: IntelligenceUtilities.post) + let text = try ContentExtractor.extractRelevantText(from: ContentExtractor.post) #expect(text == """

The Art of Making Perfect Sourdough Bread at Home

@@ -52,7 +52,7 @@ struct IntelligenceUtilitiesTests { /// Blockquote contain nested block and the implementation should account for that. @Test func blockquotes() throws { - let text = try IntelligenceUtilities.extractRelevantText(from: """ + let text = try ContentExtractor.extractRelevantText(from: """

Welcome to WordPress! This is your first post. Edit or delete it to take the first step in your blogging journey.

@@ -71,13 +71,13 @@ struct IntelligenceUtilitiesTests { } @Test func extractRelevantTextFromPlainText() throws { - let text = try IntelligenceUtilities.extractRelevantText(from: "This is a plain text post") + let text = try ContentExtractor.extractRelevantText(from: "This is a plain text post") #expect(text == "This is a plain text post") } } -extension IntelligenceUtilities { +extension ContentExtractor { static let post = """

The Art of Making Perfect Sourdough Bread at Home

diff --git a/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift b/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift new file mode 100644 index 000000000000..b39f98be0491 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift @@ -0,0 +1,115 @@ +import Testing +import FoundationModels +@testable import WordPressIntelligence + +/// Tests for excerpt generation with different locales and languages. +/// +/// These tests verify that excerpts are generated correctly for content in various +/// languages and match the specified length and style requirements. +/// +/// The entire test suite is disabled by default and should be run manually to verify +/// the generated excerpts meet quality standards. +/// +/// ## What to Verify +/// +/// When running these tests, verify that the generated excerpts: +/// +/// **Language and Content** +/// - Are in the same language as the source content +/// - Are relevant to the source content topic +/// - Properly handle HTML tags (extracted/ignored, not visible in output) +/// - Work with special characters (accents, symbols) +/// +/// **Length Requirements** +/// - Short: 20-40 words +/// - Medium: 50-70 words +/// - Long: 120-180 words +/// - Japanese: Appropriate character count (not word count) +/// +/// **Style Requirements** +/// - Engaging: Engaging and compelling tone +/// - Conversational: Friendly and conversational tone +/// - Witty: Witty, creative, and entertaining +/// - Formal: Formal and academic tone +/// - Professional: Professional and polished tone +/// - Each style should have a distinctly different tone +/// +/// **Excerpt Best Practices** +/// - Include the post's main value proposition +/// - Use active voice (avoid "is", "are", "was", "were" when possible) +/// - End with implicit promise of more information +/// - No ellipsis (...) at the end +/// - Focus on value, not just summary +/// - Work as standalone content (independent from introduction) +struct IntelligenceExcerptGenerationTests { + + @available(iOS 26, *) + @Test(.serialized, arguments: ExcerptTestCaseParameters.allCases) + func excerptGeneration(parameters: ExcerptTestCaseParameters) async throws { + let generator = ExcerptGeneration(length: parameters.length, style: parameters.style) + let excerpts = try await generator.generate(for: parameters.data.content) + + TestHelpers.printExcerptResults( + parameters.testDescription, + excerpts: excerpts, + targetLength: parameters.length.promptModifier, + style: parameters.style.displayName + ) + } + + @available(iOS 26, *) + @Test("Spanish HTML content") + func spanishHTMLContent() async throws { + let testData = TestData.spanishPostWithHTML + let generator = ExcerptGeneration(length: .medium, style: .engaging) + let excerpts = try await generator.generate(for: testData.content) + + TestHelpers.printExcerptResults( + testData.title, + excerpts: excerpts, + targetLength: generator.length.promptModifier, + style: generator.style.displayName + ) + } + + @available(iOS 26, *) + @Test("Very short content") + func veryShortContent() async throws { + let shortContent = "La inteligencia artificial está transformando nuestro mundo." + + let generator = ExcerptGeneration(length: .short, style: .engaging) + let excerpts = try await generator.generate(for: shortContent) + + TestHelpers.printExcerptResults( + "Very short content", + excerpts: excerpts, + targetLength: generator.length.promptModifier, + style: generator.style.displayName + ) + } +} + +struct ExcerptTestCaseParameters: CustomTestStringConvertible { + let data: TestContent + let length: ContentLength + let style: WritingStyle + + var testDescription: String { + "\(data.title) - \(length.displayName) \(style.displayName)" + } + + typealias Data = TestData + + static let allCases: [ExcerptTestCaseParameters] = [ + // English + ExcerptTestCaseParameters(data: Data.englishTechPost, length: .short, style: .witty), + ExcerptTestCaseParameters(data: Data.englishAcademicPost, length: .medium, style: .formal), + ExcerptTestCaseParameters(data: Data.englishStoryPost, length: .long, style: .engaging), + + // Other + ExcerptTestCaseParameters(data: Data.spanishPost, length: .medium, style: .professional), + ExcerptTestCaseParameters(data: Data.frenchPost, length: .short, style: .engaging), + ExcerptTestCaseParameters(data: Data.japanesePost, length: .medium, style: .conversational), + ExcerptTestCaseParameters(data: Data.germanTechPost, length: .short, style: .professional), + ] +} diff --git a/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift new file mode 100644 index 000000000000..2895f5208f45 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift @@ -0,0 +1,124 @@ +import Testing +@testable import WordPressIntelligence + +/// Tests for post summarization with different locales and languages. +/// +/// These tests verify that post summaries are generated correctly for content in various +/// languages. The entire test suite is disabled by default and should be run manually +/// to verify the generated summaries match expectations. +/// +/// ## What to Verify +/// +/// When running these tests, verify that the generated summaries: +/// +/// **Language and Content** +/// - Are in the same language as the source content +/// - Capture the main points of the article +/// - Are concise and informative (typically 2-4 sentences) +/// - Use a neutral, professional tone +/// +/// **For Reader Content** +/// - Are suitable for Reader context (news articles, blog posts) +/// - Provide clear overview of the article's main topic +/// - Maintain professional and informative tone +/// +/// **HTML Content** +/// - Properly handle/ignore HTML tags +/// - Extract content correctly without HTML artifacts +/// +/// To test different system locales: +/// 1. Change the scheme's Application Language in Xcode +/// 2. Run the test suite manually +/// 3. Verify the generated summaries match the expected language and tone +@Suite(.disabled("Manual tests - Run individually to test post summaries")) +struct IntelligencePostSummaryTests { + + // MARK: - Parameterized Language Tests + + @available(iOS 26, *) + @Test(arguments: SummaryTestCase.basicCases) + func postSummary(testCase: SummaryTestCase) async throws { + let summary = try await summarizePost(content: testCase.testData.content) + + TestHelpers.printSummaryResults( + testCase.testData.title, + summary: summary + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + // MARK: - Special Content Tests + + typealias Data = TestData + + @available(iOS 26, *) + @Test("Summarize HTML-heavy Spanish post") + func spanishHTMLContent() async throws { + let testData = Data.spanishPostWithHTML + let summary = try await summarizePost(content: testData.content) + + TestHelpers.printSummaryResults( + testData.title, + summary: summary + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @available(iOS 26, *) + @Test("Reader summary for Spanish article") + func readerSummarySpanishContent() async throws { + let testData = Data.spanishReaderArticle + let summary = try await summarizePost(content: testData.content) + + TestHelpers.printSummaryResults( + testData.title, + summary: summary + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @available(iOS 26, *) + @Test("Reader summary for English article") + func readerSummaryEnglishContent() async throws { + let testData = Data.englishReaderArticle + let summary = try await summarizePost(content: testData.content) + + TestHelpers.printSummaryResults( + testData.title, + summary: summary + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + // MARK: - Helper Methods + + @available(iOS 26, *) + private func summarizePost(content: String) async throws -> String { + let service = IntelligenceService() + let summary = try await service.summarizePost(content: content) + print(summary) + return summary + } +} + +// MARK: - Test Cases + +struct SummaryTestCase: CustomTestStringConvertible { + let testData: TestContent + + var testDescription: String { testData.title } + + typealias Data = TestData + + static let basicCases: [SummaryTestCase] = [ + SummaryTestCase(testData: Data.spanishPost), + SummaryTestCase(testData: Data.englishPost), + SummaryTestCase(testData: Data.frenchPost), + SummaryTestCase(testData: Data.japanesePost), + ] +} + diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift similarity index 79% rename from Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift rename to Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift index b9f4a8adccf6..e606ae23d5c8 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import WordPressShared +@testable import WordPressIntelligence struct IntelligenceServiceTests { @available(iOS 26, *) @@ -7,7 +7,7 @@ struct IntelligenceServiceTests { func suggestTags() async throws { let tags = try await IntelligenceService() .suggestTags( - post: IntelligenceUtilities.post, + post: ContentExtractor.post, siteTags: ["cooking", "healthy-foods"] ) print(tags) diff --git a/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift new file mode 100644 index 000000000000..2bffc8eedb92 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift @@ -0,0 +1,170 @@ +import Testing +@testable import WordPressIntelligence + +/// Tests for tag suggestion with different locales and languages. +/// +/// These tests verify that tag suggestions work correctly with content in various +/// languages and locale settings. The entire test suite is disabled by default +/// and should be run manually to verify the generated tags match expectations. +/// +/// ## What to Verify +/// +/// When running these tests, verify that the generated tags: +/// +/// **Language Matching** +/// - Tags should match the language of the site tags (if provided) +/// - If no site tags: tags should match the content language +/// - Mixed language content: tags match dominant language +/// +/// **Format and Style** +/// - Use lowercase-hyphenated format (e.g., "spanish-cuisine") +/// - Japanese tags may use native characters without hyphens +/// - Consistent formatting across all generated tags +/// +/// **Relevance and Quality** +/// - Are relevant to the post content +/// - Don't duplicate existing post tags +/// - Are appropriate for the topic (cooking, technology, etc.) +/// - Make sense as blog post tags +/// +/// **Edge Cases** +/// - Work with empty site tags +/// - Work with very short content +/// - Work with very long content (truncated appropriately) +/// - Properly exclude existing post tags +/// +/// To test different system locales: +/// 1. Change the scheme's Application Language in Xcode +/// 2. Run the test suite manually +/// 3. Verify the generated tags match the expected language and format +@Suite(.disabled("Manual tests - Run individually to test tag suggestions")) +struct IntelligenceSuggestedTagsTests { + + // MARK: - Parameterized Language Combination Tests + + @available(iOS 26, *) + @Test(arguments: TagTestCase.languageCombinations) + func tagSuggestion(testCase: TagTestCase) async throws { + let tags = try await suggestTags( + post: testCase.testData.content, + siteTags: testCase.siteTags, + postTags: testCase.postTags + ) + + TestHelpers.printTagResults( + testCase.testDescription, + tags: tags + ) + } + + // MARK: - Edge Case Tests + + typealias Data = TestData + + @available(iOS 26, *) + @Test("Existing post tags should be excluded") + func excludeExistingTags() async throws { + let testData = Data.spanishPost + let existingTags = ["recetas", "cocina"] + let tags = try await suggestTags( + post: testData.content, + siteTags: Data.spanishSiteTags, + postTags: existingTags + ) + + TestHelpers.printTagResults( + "\(testData.title) - Exclude Existing Tags: \(existingTags.joined(separator: ", "))", + tags: tags + ) + + #expect(!tags.contains { existingTags.contains($0) }) + } + + @available(iOS 26, *) + @Test("Empty site tags") + func emptySiteTags() async throws { + let testData = Data.spanishPost + let tags = try await suggestTags( + post: testData.content, + siteTags: [], + postTags: [] + ) + + TestHelpers.printTagResults( + "\(testData.title) - No Site Tags Context", + tags: tags + ) + } + + @available(iOS 26, *) + @Test("Very short content") + func shortContent() async throws { + let tags = try await suggestTags( + post: "Deliciosa receta de gazpacho andaluz.", + siteTags: Data.spanishSiteTags, + postTags: [] + ) + + TestHelpers.printTagResults( + "Spanish - Very Short Content", + tags: tags + ) + } + + @available(iOS 26, *) + @Test("Very long content") + func longContent() async throws { + let testData = Data.spanishPost + let longContent = String(repeating: testData.content + "\n\n", count: 5) + let tags = try await suggestTags( + post: longContent, + siteTags: Data.spanishSiteTags, + postTags: [] + ) + + TestHelpers.printTagResults( + "\(testData.title) - Very Long Content (Truncated)", + tags: tags + ) + } + + // MARK: - Helper Methods + + @available(iOS 26, *) + private func suggestTags(post: String, siteTags: [String], postTags: [String]) async throws -> [String] { + try await IntelligenceService().suggestTags( + post: post, + siteTags: siteTags, + postTags: postTags + ) + } +} + +// MARK: - Test Cases + +struct TagTestCase: CustomTestStringConvertible { + let testData: TestContent + let siteTags: [String] + let postTags: [String] + let siteTagsLanguage: String? + + var testDescription: String { + if let siteTagsLanguage = siteTagsLanguage { + return "\(testData.title) → \(siteTagsLanguage) Site Tags" + } else { + return testData.title + } + } + + typealias Data = TestData + + static let languageCombinations: [TagTestCase] = [ + TagTestCase(testData: Data.spanishPost, siteTags: Data.spanishSiteTags, postTags: [], siteTagsLanguage: "Spanish"), + TagTestCase(testData: Data.spanishPost, siteTags: Data.englishSiteTags, postTags: [], siteTagsLanguage: "English"), + TagTestCase(testData: Data.englishPost, siteTags: Data.spanishSiteTags, postTags: [], siteTagsLanguage: "Spanish"), + TagTestCase(testData: Data.mixedLanguagePost, siteTags: Data.englishSiteTags, postTags: [], siteTagsLanguage: "Dominant language"), + TagTestCase(testData: Data.frenchPost, siteTags: Data.frenchSiteTags, postTags: [], siteTagsLanguage: "French"), + TagTestCase(testData: Data.japanesePost, siteTags: Data.japaneseSiteTags, postTags: [], siteTagsLanguage: "Japanese"), + ] +} + diff --git a/Modules/Tests/WordPressIntelligenceTests/TestData.swift b/Modules/Tests/WordPressIntelligenceTests/TestData.swift new file mode 100644 index 000000000000..f2356d9d9d4f --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/TestData.swift @@ -0,0 +1,265 @@ +import Foundation + +/// Test content with title and body for intelligence service tests. +struct TestContent { + let title: String + let content: String +} + +/// Shared test data for intelligence service tests. +/// +/// This enum provides sample content in multiple languages for testing +/// excerpt generation, post summarization, and tag suggestion features. +enum TestData { + // MARK: - Spanish Content + + static let spanishPostWithHTML = TestContent( + title: "Spanish Post with HTML", + content: """ + +

Receta Auténtica de Paella Valenciana

+ + + +

La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. + Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad + de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón.

+ + + +

Ingredientes Principales

+ + + +
    +
  • 400g de arroz bomba
  • +
  • 1 pollo troceado
  • +
  • 200g de judías verdes
  • +
  • 100g de garrofón
  • +
  • Azafrán en hebras
  • +
  • Aceite de oliva virgen extra
  • +
+ + + +

La clave está en el sofrito y el punto exacto del arroz. El azafrán aporta ese + característico color dorado y sabor único.

+ + """ + ) + + static let spanishPost = TestContent( + title: "Spanish Post", + content: """ + La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. + Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad + de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. + + La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. + El azafrán no solo aporta ese característico color dorado, sino también un sabor único + e inconfundible. + + Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la + variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para + conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. + """ + ) + + static let spanishReaderArticle = TestContent( + title: "Spanish Reader Article", + content: """ + El cambio climático está afectando de manera significativa a los ecosistemas marinos + del Mediterráneo. Científicos del CSIC han documentado un aumento de 2 grados en la + temperatura media del agua durante los últimos 30 años, lo que ha provocado cambios + en las rutas migratorias de varias especies de peces y la proliferación de especies + invasoras procedentes de aguas más cálidas. + """ + ) + + // MARK: - English Content + + static let englishTechPost = TestContent( + title: "English Tech Post", + content: """ + Quantum computing represents a paradigm shift in how we approach computational problems. Unlike + classical computers that use bits (0s and 1s), quantum computers leverage qubits that can exist + in superposition, simultaneously representing multiple states. + + This fundamental difference enables quantum computers to tackle problems that are intractable + for classical machines. Drug discovery, cryptography, optimization, and climate modeling are + just a few domains poised for revolutionary breakthroughs. + + However, significant challenges remain. Quantum systems are incredibly fragile, requiring + near-absolute-zero temperatures and isolation from environmental interference. Error correction + is another major hurdle, as quantum states are prone to decoherence. + """ + ) + + static let englishAcademicPost = TestContent( + title: "English Academic Post", + content: """ + The phenomenon of linguistic relativity, often referred to as the Sapir-Whorf hypothesis, + posits that the structure of a language influences its speakers' worldview and cognition. + While the strong version of this hypothesis has been largely discredited, contemporary research + suggests more nuanced relationships between language and thought. + + Recent studies in cognitive linguistics have demonstrated that language can indeed affect + perception and categorization, particularly in domains like color perception, spatial reasoning, + and temporal cognition. However, these effects are context-dependent and vary significantly + across different cognitive domains. + + Cross-linguistic research continues to provide valuable insights into the universal and + language-specific aspects of human cognition, challenging researchers to refine their + theoretical frameworks and methodological approaches. + """ + ) + + static let englishStoryPost = TestContent( + title: "English Story Post", + content: """ + The old lighthouse keeper had seen many storms in his forty years tending the beacon, but + none quite like the tempest that rolled in that October evening. Dark clouds gathered on + the horizon like an invading army, their edges tinged with an unsettling green hue. + + As the first drops of rain pelted the lighthouse windows, Magnus checked the lamp one final + time. The beam cut through the gathering darkness, a lifeline for any vessels brave or foolish + enough to be out on such a night. He'd heard the coastguard warnings on the radio—winds + exceeding 90 miles per hour, waves reaching heights of 30 feet. + + Down in the keeper's quarters, Magnus brewed strong coffee and settled into his worn leather + chair. Outside, the wind howled like a wounded beast, but within these thick stone walls, + he felt safe. This lighthouse had withstood two centuries of nature's fury; it would stand + through one more night. + """ + ) + + static let englishPost = TestContent( + title: "English Post", + content: """ + Sourdough bread has experienced a remarkable revival in recent years, with home bakers + around the world rediscovering this ancient craft. The natural fermentation process + creates a distinctive tangy flavor and numerous health benefits. + + The key to successful sourdough lies in maintaining a healthy starter culture. This + living mixture of flour and water harbors wild yeast and beneficial bacteria that + work together to leaven the bread and develop complex flavors. + + Temperature and timing are crucial factors. The fermentation process can take anywhere + from 12 to 24 hours, depending on ambient temperature and the activity of your starter. + """ + ) + + static let englishReaderArticle = TestContent( + title: "English Reader Article", + content: """ + Recent advances in quantum computing have brought us closer to solving complex problems + that are impossible for classical computers. Google's quantum processor achieved + quantum supremacy by performing a calculation in 200 seconds that would take the world's + fastest supercomputer 10,000 years to complete. However, practical applications for + everyday computing are still years away. + """ + ) + + // MARK: - French Content + + static let frenchPost = TestContent( + title: "French Post", + content: """ + La cuisine française est reconnue mondialement pour sa finesse et sa diversité. + Du coq au vin bourguignon au délicieux cassoulet du Sud-Ouest, chaque région possède + ses spécialités qui racontent une histoire culinaire unique. + + Les techniques de base de la cuisine française, comme le mirepoix, le roux, et les + cinq sauces mères, constituent le fondement de nombreuses préparations classiques. + Ces méthodes transmises de génération en génération permettent de créer des plats + d'une grande complexité et raffinement. + + L'utilisation d'ingrédients frais et de saison est primordiale. Les marchés locaux + offrent une abondance de produits qui inspirent les chefs et les cuisiniers amateurs. + """ + ) + + // MARK: - Japanese Content + + static let japanesePost = TestContent( + title: "Japanese Post", + content: """ + 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 + この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 + + 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 + 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 + + 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 + インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 + """ + ) + + // MARK: - German Content + + static let germanTechPost = TestContent( + title: "German Tech Post", + content: """ + Die deutsche Automobilindustrie steht vor einem beispiellosen Wandel. Der Übergang von + Verbrennungsmotoren zu Elektroantrieben erfordert nicht nur technologische Innovation, + sondern auch eine grundlegende Neuausrichtung der gesamten Wertschöpfungskette. + + Traditionelle Zulieferer müssen sich anpassen oder riskieren, obsolet zu werden. Gleichzeitig + entstehen neue Geschäftsmodelle rund um Batterietechnologie, Ladeinfrastruktur und + Software-definierte Fahrzeuge. Die Frage ist nicht mehr, ob dieser Wandel kommt, sondern + wie schnell deutsche Unternehmen sich anpassen können, um ihre führende Position in der + globalen Automobilbranche zu behalten. + """ + ) + + // MARK: - Mixed Language Content + + static let mixedLanguagePost = TestContent( + title: "Mixed Language Post", + content: """ + The Mediterranean Diet: Una Guía Completa + + The Mediterranean diet has been recognized by UNESCO as an Intangible Cultural Heritage + of Humanity. Esta dieta tradicional se basa en el consumo de aceite de oliva, frutas, + verduras, legumbres, y pescado. + + Los beneficios para la salud son numerosos: reduced risk of heart disease, mejor + control del peso, y longevidad aumentada. Studies have shown that people who follow + this diet tend to live longer and healthier lives. + """ + ) + + // MARK: - Tag Data + + static let spanishSiteTags = [ + "recetas", + "cocina-española", + "gastronomía", + "comida-mediterránea", + "platos-tradicionales" + ] + + static let englishSiteTags = [ + "baking", + "bread-making", + "recipes", + "sourdough", + "homemade" + ] + + static let frenchSiteTags = [ + "cuisine", + "gastronomie-française", + "recettes", + "plats-traditionnels", + "art-culinaire" + ] + + static let japaneseSiteTags = [ + "日本料理", + "レシピ", + "料理", + "伝統", + "和食" + ] +} diff --git a/Modules/Tests/WordPressIntelligenceTests/TestHelpers.swift b/Modules/Tests/WordPressIntelligenceTests/TestHelpers.swift new file mode 100644 index 000000000000..aeaf9ef71468 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/TestHelpers.swift @@ -0,0 +1,198 @@ +import Foundation + +/// Helper utilities for formatting intelligence test output. +enum TestHelpers { + + // MARK: - Tag Suggestions + + static func printTagResults( + _ title: String, + tags: [String] + ) { + printSectionHeader(title) + + print("📑 Generated \(tags.count) tags:") + print() + for (i, tag) in tags.enumerated() { + print(" \(i + 1). \(tag)") + } + + printSectionFooter() + } + + // MARK: - Summaries + + static func printSummaryResults( + _ title: String, + summary: String + ) { + printSectionHeader(title) + + let wordCount = summary.split(separator: " ").count + let charCount = summary.count + print("📊 Metrics: \(wordCount) words • \(charCount) characters") + print() + print("📝 Summary:") + print() + print(summary.wrapped(width: 80)) + + printSectionFooter() + } + + // MARK: - Excerpts + + static func printExcerptResults( + _ title: String, + excerpts: [String], + targetLength: String, + style: String + ) { + printSectionHeader(title) + + print("🎯 Target: \(targetLength) • Style: \(style)") + print() + print("📝 Generated \(excerpts.count) variations:") + print() + + for (i, excerpt) in excerpts.enumerated() { + let wordCount = excerpt.split(separator: " ").count + print("┌─ Variation \(i + 1) (\(wordCount) words) " + String(repeating: "─", count: max(0, 70 - "\(i + 1)".count - "\(wordCount)".count))) + print("│") + for line in excerpt.wrapped(width: 78).split(separator: "\n") { + print("│ \(line)") + } + print("│") + print("└" + String(repeating: "─", count: 78)) + print() + } + } + + // MARK: - Comparison Tables + + static func printComparisonTable( + _ title: String, + headers: [String], + rows: [[String]] + ) { + printSectionHeader(title) + + // Calculate column widths + var widths = headers.map { $0.count } + for row in rows { + for (i, cell) in row.enumerated() where i < widths.count { + widths[i] = max(widths[i], cell.count) + } + } + + // Print header + print("┌", terminator: "") + for (i, width) in widths.enumerated() { + print(String(repeating: "─", count: width + 2), terminator: "") + print(i < widths.count - 1 ? "┬" : "┐\n", terminator: "") + } + + print("│", terminator: "") + for (i, header) in headers.enumerated() { + print(" \(header.padding(toLength: widths[i], withPad: " ", startingAt: 0)) ", terminator: "") + print(i < headers.count - 1 ? "│" : "│\n", terminator: "") + } + + // Print separator + print("├", terminator: "") + for (i, width) in widths.enumerated() { + print(String(repeating: "─", count: width + 2), terminator: "") + print(i < widths.count - 1 ? "┼" : "┤\n", terminator: "") + } + + // Print rows + for row in rows { + print("│", terminator: "") + for (i, cell) in row.enumerated() where i < widths.count { + print(" \(cell.padding(toLength: widths[i], withPad: " ", startingAt: 0)) ", terminator: "") + print(i < row.count - 1 ? "│" : "│\n", terminator: "") + } + } + + // Print footer + print("└", terminator: "") + for (i, width) in widths.enumerated() { + print(String(repeating: "─", count: width + 2), terminator: "") + print(i < widths.count - 1 ? "┴" : "┘\n", terminator: "") + } + + printSectionFooter() + } + + // MARK: - Utilities + + private static func printSectionHeader(_ title: String) { + print() + print("╔" + String(repeating: "═", count: 78) + "╗") + + // Extract language from title (first word) + let language = title.split(separator: " ").first.map(String.init) + if let language = language { + let flag = languageFlag(for: language) + print("║ \(flag) \(title)") + } else { + print("║ \(title)") + } + + print("╠" + String(repeating: "═", count: 78) + "╣") + print() + } + + private static func printSectionFooter() { + print("╚" + String(repeating: "═", count: 78) + "╝") + print() + } + + private static func languageFlag(for language: String) -> String { + switch language.lowercased() { + case "spanish": return "🇪🇸" + case "english": return "🇬🇧" + case "french": return "🇫🇷" + case "japanese": return "🇯🇵" + case "german": return "🇩🇪" + case "mixed": return "🌐" + case "dominant": return "🌐" + default: return "🌍" + } + } +} + +// MARK: - String Extensions + +private extension String { + /// Wraps text to specified width while preserving words + func wrapped(width: Int) -> String { + var result = "" + var currentLine = "" + var currentWidth = 0 + + for word in self.split(separator: " ") { + let wordWidth = word.count + + if currentWidth + wordWidth + 1 > width { + if !result.isEmpty { + result += "\n" + } + result += currentLine.trimmingCharacters(in: .whitespaces) + currentLine = String(word) + " " + currentWidth = wordWidth + 1 + } else { + currentLine += word + " " + currentWidth += wordWidth + 1 + } + } + + if !currentLine.isEmpty { + if !result.isEmpty { + result += "\n" + } + result += currentLine.trimmingCharacters(in: .whitespaces) + } + + return result + } +} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 1eb9adfb1ca9..cef50b8bdb9f 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,6 @@ 26.6 ----- - +* [**] [Intelligence] Expand AI-based features to more locales [#25034] 26.5 ----- diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 9f344b213e5f..16055bf9c4fc 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -1,5 +1,6 @@ import BuildSettingsKit import Foundation +import FoundationModels /// FeatureFlag exposes a series of features to be conditionally enabled on /// different builds. @@ -80,8 +81,10 @@ public enum FeatureFlag: Int, CaseIterable { case .newStats: return false case .intelligence: - let languageCode = Locale.current.language.languageCode?.identifier - return (languageCode ?? "en").hasPrefix("en") + guard #available(iOS 26, *) else { + return false + } + return SystemLanguageModel.default.supportsLocale() case .newSupport: return false case .nativeBlockInserter: diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift index 668818775e53..9c2b42bd1c03 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift @@ -42,7 +42,7 @@ struct PostSettingsExcerptEditor: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - if FeatureFlag.intelligence.enabled && !postContent.isEmpty && LanguageModelHelper.isSupported { + if FeatureFlag.intelligence.enabled && !postContent.isEmpty && IntelligenceService.isSupported { if #available(iOS 26, *) { PostSettingsGenerateExcerptButton( content: postContent, diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift index a5f6f72b2be4..3673f5e09aa6 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift @@ -2,6 +2,7 @@ import SwiftUI import WordPressUI import DesignSystem import FoundationModels +import WordPressShared @available(iOS 26, *) struct PostSettingsGenerateExcerptView: View { @@ -11,10 +12,10 @@ struct PostSettingsGenerateExcerptView: View { @Environment(\.dismiss) private var dismiss @AppStorage("jetpack_ai_generated_excerpt_style") - private var style: GenerationStyle = .engaging + private var style: IntelligenceService.ExcerptGeneration.Style = .engaging @AppStorage("jetpack_ai_generated_excerpt_length") - private var length: GeneratedContentLength = .medium + private var length: IntelligenceService.ExcerptGeneration.Length = .medium @State private var results: [ExcerptGenerationResult.PartiallyGenerated] = [] @State private var isGenerating = false @@ -162,9 +163,9 @@ struct PostSettingsGenerateExcerptView: View { Slider( value: Binding( get: { Double(length.rawValue) }, - set: { length = GeneratedContentLength(rawValue: Int($0)) ?? .medium } + set: { length = IntelligenceService.ExcerptGeneration.Length(rawValue: Int($0)) ?? .medium } ), - in: 0...Double(GeneratedContentLength.allCases.count - 1), + in: 0...Double(IntelligenceService.ExcerptGeneration.Length.allCases.count - 1), step: 1 ) { Text(Strings.lengthSliderAccessibilityLabel) @@ -199,7 +200,7 @@ struct PostSettingsGenerateExcerptView: View { Spacer(minLength: 8) Picker(Strings.stylePickerAccessibilityLabel, selection: $style) { - ForEach(GenerationStyle.allCases, id: \.self) { style in + ForEach(IntelligenceService.ExcerptGeneration.Style.allCases, id: \.self) { style in Text(style.displayName) .tag(style) } @@ -230,10 +231,8 @@ struct PostSettingsGenerateExcerptView: View { generationTask = Task { do { - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: LanguageModelHelper.generateExcerptInstructions - ) + let generator = IntelligenceService.ExcerptGeneration(length: length, style: style) + let session = generator.makeSession() self.session = session try await actuallyGenerateExcerpts(in: session) } catch { @@ -273,8 +272,9 @@ struct PostSettingsGenerateExcerptView: View { isGenerating = false } + let generator = IntelligenceService.ExcerptGeneration(length: length, style: style) let content = IntelligenceService().extractRelevantText(from: postContent) - let prompt = isLoadMore ? LanguageModelHelper.generateMoreOptionsPrompt : LanguageModelHelper.makeGenerateExcerptPrompt(content: content, length: length, style: style) + let prompt = isLoadMore ? IntelligenceService.ExcerptGeneration.loadMorePrompt : generator.makePrompt(content: content) let stream = session.streamResponse(to: prompt, generating: ExcerptGenerationResult.self) for try await result in stream { @@ -299,7 +299,7 @@ struct PostSettingsGenerateExcerptView: View { WPAnalytics.track(.intelligenceExcerptOptionsGenerated, properties: [ "length": length.trackingName, "style": style.rawValue, - "load_more": isLoadMore ? 1 : 0 + "load_more": isLoadMore ]) } } diff --git a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift index 7cf11dcdb604..a99dd54a4530 100644 --- a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift @@ -1,7 +1,6 @@ import SwiftUI import WordPressUI import WordPressData -import FoundationModels @available(iOS 26, *) struct ReaderSummarizePostView: View { @@ -72,13 +71,11 @@ struct ReaderSummarizePostView: View { do { let content = post.content ?? "" - let stream = await IntelligenceService().summarizePost(content: content) + let result = try await IntelligenceService().summarizePost(content: content) - for try await result in stream { - guard !Task.isCancelled else { return } - withAnimation(.smooth) { - summary = result.content - } + guard !Task.isCancelled else { return } + withAnimation(.smooth) { + summary = result } } catch { guard !Task.isCancelled else { return }