diff --git a/Modules/Package.swift b/Modules/Package.swift index 05537222fc35..655d6e3d57fe 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,11 @@ 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")], + resources: [.process("Resources")] + ) ] ) @@ -348,6 +358,7 @@ enum XcodeSupport { "ShareExtensionCore", "Support", "WordPressFlux", + "WordPressIntelligence", "WordPressShared", "WordPressLegacy", "WordPressReader", diff --git a/Modules/Sources/WordPressIntelligence/IntelligenceService.swift b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift new file mode 100644 index 000000000000..b8c34923997d --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift @@ -0,0 +1,320 @@ +import Foundation +import FoundationModels +import NaturalLanguage +import Vision +import UIKit +import WordPressShared + +public enum 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 + } + } + } + + /// Extracts relevant text from post content, removing HTML and limiting size. + public 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))) + } + + /// - note: As documented in https://developer.apple.com/documentation/foundationmodels/supporting-languages-and-locales-with-foundation-models?changes=_10_5#Use-Instructions-to-set-the-locale-and-language + static func makeLocaleInstructions(for locale: Locale = Locale.current) -> String { + if Locale.Language(identifier: "en_US").isEquivalent(to: locale.language) { + return "" // Skip the locale phrase for U.S. English. + } + return "The person's locale is \(locale.identifier)." + } + + /// Detects the dominant language of the given text. + /// + /// - Parameter text: The text to analyze + /// - Returns: The detected language code (e.g., "en", "es", "fr", "ja"), or nil if detection fails + public static func detectLanguage(from text: String) -> String? { + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + + guard let languageCode = recognizer.dominantLanguage else { + return nil + } + + return languageCode.rawValue + } + + /// Analyzes an image using Vision framework to extract comprehensive visual information. + /// + /// Uses multiple Vision APIs to gather detailed information about the image: + /// - Image classification for scene and object identification + /// - Text recognition for readable content + /// - Face detection and landmarks for portraits + /// - Human and animal detection for subjects + /// - Saliency analysis for key regions of interest + /// - Horizon detection for landscape orientation + /// - Barcode detection for QR codes and barcodes + /// - Document detection for papers and screenshots + /// + /// - Parameter cgImage: The image to analyze + /// - Returns: A JSON string with structured analysis data + /// - Throws: If image analysis fails + @available(iOS 26, *) + public static func analyzeImage(_ cgImage: CGImage) async throws -> String { + let startTime = CFAbsoluteTimeGetCurrent() + + // Create all analysis requests + let classifyRequest = VNClassifyImageRequest() + let textRequest = VNRecognizeTextRequest() + textRequest.recognitionLevel = .accurate + textRequest.usesLanguageCorrection = true + + let faceRequest = VNDetectFaceRectanglesRequest() + let faceLandmarksRequest = VNDetectFaceLandmarksRequest() + let humanRequest = VNDetectHumanRectanglesRequest() + let animalRequest = VNRecognizeAnimalsRequest() + let saliencyRequest = VNGenerateAttentionBasedSaliencyImageRequest() + let horizonRequest = VNDetectHorizonRequest() + let barcodeRequest = VNDetectBarcodesRequest() + let documentRequest = VNDetectDocumentSegmentationRequest() + + // Perform all requests + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + try handler.perform([ + classifyRequest, + textRequest, + faceRequest, + faceLandmarksRequest, + humanRequest, + animalRequest, + saliencyRequest, + horizonRequest, + barcodeRequest, + documentRequest + ]) + + // Build structured analysis result + var analysis: [String: Any] = [:] + + // Image dimensions + analysis["imageSize"] = [ + "width": cgImage.width, + "height": cgImage.height + ] + + let aspectRatio = Double(cgImage.width) / Double(cgImage.height) + if aspectRatio > 1.5 { + analysis["orientation"] = "landscape" + } else if aspectRatio < 0.7 { + analysis["orientation"] = "portrait" + } else { + analysis["orientation"] = "square" + } + + // 1. Scene/Object Classification + if let classifications = classifyRequest.results?.prefix(5) { + let labels = classifications + .filter { $0.confidence > 0.3 } + .map { [ + "label": $0.identifier.replacingOccurrences(of: "_", with: " "), + "confidence": Int($0.confidence * 100) + ] as [String: Any] } + if !labels.isEmpty { + analysis["sceneClassification"] = labels + } + } + + // 2. Face Detection with Landmarks + var facesData: [[String: Any]] = [] + if let faceObservations = faceLandmarksRequest.results, !faceObservations.isEmpty { + for face in faceObservations { + var faceInfo: [String: Any] = [:] + + // Position + let bounds = face.boundingBox + if bounds.origin.x < 0.33 { + faceInfo["horizontalPosition"] = "left" + } else if bounds.origin.x > 0.66 { + faceInfo["horizontalPosition"] = "right" + } else { + faceInfo["horizontalPosition"] = "center" + } + + if bounds.origin.y < 0.33 { + faceInfo["verticalPosition"] = "bottom" + } else if bounds.origin.y > 0.66 { + faceInfo["verticalPosition"] = "top" + } else { + faceInfo["verticalPosition"] = "middle" + } + + // Size (relative to image) + let faceArea = bounds.width * bounds.height + if faceArea > 0.25 { + faceInfo["size"] = "closeup" + } else if faceArea > 0.1 { + faceInfo["size"] = "medium" + } else { + faceInfo["size"] = "distant" + } + + // Landmarks details + if let landmarks = face.landmarks { + var landmarksInfo: [String] = [] + if landmarks.faceContour != nil { landmarksInfo.append("face contour") } + if landmarks.leftEye != nil { landmarksInfo.append("left eye") } + if landmarks.rightEye != nil { landmarksInfo.append("right eye") } + if landmarks.nose != nil { landmarksInfo.append("nose") } + if landmarks.outerLips != nil { landmarksInfo.append("mouth") } + faceInfo["detectedFeatures"] = landmarksInfo + } + + facesData.append(faceInfo) + } + analysis["faces"] = [ + "count": faceObservations.count, + "details": facesData + ] + } + + // 3. Human Detection (full body) + if let humanObservations = humanRequest.results, !humanObservations.isEmpty { + let humanData = humanObservations.map { observation -> [String: Any] in + let bounds = observation.boundingBox + return [ + "confidence": Int(observation.confidence * 100), + "size": bounds.width * bounds.height > 0.2 ? "prominent" : "background" + ] + } + analysis["humans"] = [ + "count": humanObservations.count, + "details": humanData + ] + } + + // 4. Animals + if let animalObservations = animalRequest.results, !animalObservations.isEmpty { + let animals = animalObservations + .filter { $0.confidence > 0.5 } + .compactMap { observation -> [String: Any]? in + guard let label = observation.labels.first else { return nil } + return [ + "type": label.identifier, + "confidence": Int(label.confidence * 100) + ] + } + if !animals.isEmpty { + analysis["animals"] = animals + } + } + + // 5. Saliency (regions of interest) + if let saliencyObservations = saliencyRequest.results as? [VNSaliencyImageObservation], + let observation = saliencyObservations.first, + let salientObjects = observation.salientObjects, !salientObjects.isEmpty { + let regions = salientObjects.map { object -> [String: Any] in + let bounds = object.boundingBox + var position = "" + if bounds.origin.x < 0.33 { + position = "left" + } else if bounds.origin.x > 0.66 { + position = "right" + } else { + position = "center" + } + return [ + "position": position, + "confidence": Int(object.confidence * 100) + ] + } + analysis["regionsOfInterest"] = [ + "count": salientObjects.count, + "regions": regions + ] + } + + // 6. Horizon detection + if let horizonObservations = horizonRequest.results, let horizon = horizonObservations.first { + let angle = horizon.angle * 180 / .pi + if abs(angle) > 5 { + analysis["horizon"] = [ + "angle": Int(angle), + "tilt": angle > 0 ? "clockwise" : "counterclockwise" + ] + } + } + + // 7. Text content + if let textObservations = textRequest.results, !textObservations.isEmpty { + let textLines = textObservations.compactMap { observation -> [String: Any]? in + guard let text = observation.topCandidates(1).first?.string else { return nil } + return [ + "text": text, + "confidence": Int(observation.confidence * 100) + ] + } + if !textLines.isEmpty { + let fullText = textLines.compactMap { $0["text"] as? String }.joined(separator: " ") + analysis["text"] = [ + "fullText": String(fullText.prefix(500)), + "lineCount": textLines.count, + "lines": textLines.prefix(10) + ] + } + } + + // 8. Barcodes/QR codes + if let barcodeObservations = barcodeRequest.results, !barcodeObservations.isEmpty { + let barcodes = barcodeObservations.compactMap { barcode -> [String: Any]? in + var barcodeInfo: [String: Any] = [ + "type": barcode.symbology.rawValue + ] + if let payload = barcode.payloadStringValue { + barcodeInfo["payload"] = payload + } + return barcodeInfo + } + analysis["barcodes"] = barcodes + } + + // 9. Document detection + if let documentObservations = documentRequest.results, !documentObservations.isEmpty { + analysis["containsDocument"] = true + analysis["documentCount"] = documentObservations.count + } + + // Convert to JSON string + let jsonData = try JSONSerialization.data(withJSONObject: analysis, options: [.prettyPrinted, .sortedKeys]) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw NSError(domain: "IntelligenceService", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to convert analysis to JSON" + ]) + } + + WPLogInfo("IntelligenceService.analyzeImage executed in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") + + return jsonString + } +} diff --git a/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift b/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift new file mode 100644 index 000000000000..ef76c7ddc1da --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift @@ -0,0 +1,58 @@ +import Foundation +import WordPressShared + +/// Target length for generated text. +/// +/// Ranges are calibrated for English and account for cross-language variance. +/// Sentences are the primary indicator; word counts accommodate language differences. +/// +/// - **Short**: 1-2 sentences (15-35 words) - Social media, search snippets +/// - **Medium**: 2-4 sentences (30-90 words) - RSS feeds, blog listings +/// - **Long**: 5-7 sentences (90-130 words) - Detailed previews, newsletters +/// +/// Word ranges are intentionally wide (2-2.3x) to handle differences in language +/// structure (German compounds, Romance wordiness, CJK tokenization). +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 { + "\(sentenceRange.lowerBound)-\(sentenceRange.upperBound) sentences (\(wordRange.lowerBound)-\(wordRange.upperBound) words)" + } + + public var sentenceRange: ClosedRange { + switch self { + case .short: 1...2 + case .medium: 2...4 + case .long: 5...7 + } + } + + public var wordRange: ClosedRange { + switch self { + case .short: 15...35 + case .medium: 40...80 + case .long: 90...130 + } + } +} diff --git a/Modules/Sources/WordPressIntelligence/Parameters/MediaMetadata.swift b/Modules/Sources/WordPressIntelligence/Parameters/MediaMetadata.swift new file mode 100644 index 000000000000..36d1dc32849f --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/Parameters/MediaMetadata.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Metadata for generating alt text and captions for media items. +public struct MediaMetadata { + public let filename: String? + public let title: String? + public let caption: String? + public let description: String? + public let altText: String? + public let fileType: String? + public let dimensions: String? + public let imageAnalysis: String? + + public init( + filename: String? = nil, + title: String? = nil, + caption: String? = nil, + description: String? = nil, + altText: String? = nil, + fileType: String? = nil, + dimensions: String? = nil, + imageAnalysis: String? = nil + ) { + self.filename = filename + self.title = title + self.caption = caption + self.description = description + self.altText = altText + self.fileType = fileType + self.dimensions = dimensions + self.imageAnalysis = imageAnalysis + } + + var hasContent: Bool { + return [filename, title, caption, description, altText, fileType, dimensions, imageAnalysis] + .contains(where: { !($0?.isEmpty ?? true) }) + } +} 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/ImageAltTextGenerator.swift b/Modules/Sources/WordPressIntelligence/UseCases/ImageAltTextGenerator.swift new file mode 100644 index 000000000000..27dba6038d87 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/ImageAltTextGenerator.swift @@ -0,0 +1,222 @@ +import Foundation +import FoundationModels +import UIKit +import WordPressShared + +/// Alt text generation for media items. +/// +/// Generates concise, descriptive, and accessible alt text for images based on +/// visual analysis and available metadata. +/// +/// Example usage: +/// ```swift +/// let generator = ImageAltTextGenerator() +/// let altText = try await generator.generate(metadata: metadata) +/// ``` +@available(iOS 26, *) +public struct ImageAltTextGenerator { + public var options: GenerationOptions + + public init(options: GenerationOptions = GenerationOptions(temperature: 0.3)) { + self.options = options + } + + /// Generates alt text for a media item. + /// + /// - Parameter metadata: The media metadata to use for generation + /// - Returns: Generated alt text + /// - Throws: If metadata is insufficient or generation fails + public func generate(metadata: MediaMetadata) async throws -> String { + guard metadata.hasContent else { + throw NSError(domain: "IntelligenceService", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Insufficient metadata to generate alt text. Please add a filename, title, or description first." + ]) + } + + let startTime = CFAbsoluteTimeGetCurrent() + let session = makeSession() + let prompt = makePrompt(metadata: metadata) + + let response = try await session.respond(to: prompt, options: options) + + WPLogInfo("ImageAltTextGenerator executed in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") + + return response.content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Generates alt text for an image with automatic Vision analysis. + /// + /// This convenience method automatically analyzes the image using Vision framework + /// and generates alt text based on the analysis combined with provided metadata. + /// + /// - Parameters: + /// - cgImage: The image to analyze and generate alt text for + /// - metadata: Additional metadata (filename, title, etc.). The imageAnalysis field will be populated automatically. + /// - Returns: Generated alt text + /// - Throws: If image analysis or generation fails + public func generate(cgImage: CGImage, metadata: MediaMetadata = MediaMetadata()) async throws -> String { + let imageAnalysis = try await IntelligenceService.analyzeImage(cgImage) + + let metadataWithAnalysis = MediaMetadata( + filename: metadata.filename, + title: metadata.title, + caption: metadata.caption, + description: metadata.description, + altText: metadata.altText, + fileType: metadata.fileType, + dimensions: metadata.dimensions, + imageAnalysis: imageAnalysis + ) + + return try await generate(metadata: metadataWithAnalysis) + } + + /// Generates alt text for an image with automatic Vision analysis. + /// + /// This convenience method automatically analyzes the image using Vision framework + /// and generates alt text based on the analysis combined with provided metadata. + /// + /// - Parameters: + /// - image: The UIImage to analyze and generate alt text for + /// - metadata: Additional metadata (filename, title, etc.). The imageAnalysis field will be populated automatically. + /// - Returns: Generated alt text + /// - Throws: If the image cannot be converted to CGImage, or if analysis/generation fails + public func generate(image: UIImage, metadata: MediaMetadata = MediaMetadata()) async throws -> String { + guard let cgImage = image.cgImage else { + throw NSError(domain: "IntelligenceService", code: -2, userInfo: [ + NSLocalizedDescriptionKey: "Unable to convert UIImage to CGImage" + ]) + } + return try await generate(cgImage: cgImage, metadata: metadata) + } + + /// Generates alt text for image data with automatic Vision analysis. + /// + /// This convenience method automatically analyzes the image using Vision framework + /// and generates alt text based on the analysis combined with provided metadata. + /// + /// - Parameters: + /// - imageData: The image data to analyze and generate alt text for + /// - metadata: Additional metadata (filename, title, etc.). The imageAnalysis field will be populated automatically. + /// - Returns: Generated alt text + /// - Throws: If the data cannot be converted to an image, or if analysis/generation fails + public func generate(imageData: Data, metadata: MediaMetadata = MediaMetadata()) async throws -> String { + guard let image = UIImage(data: imageData) else { + throw NSError(domain: "IntelligenceService", code: -3, userInfo: [ + NSLocalizedDescriptionKey: "Unable to create UIImage from data" + ]) + } + return try await generate(image: image, metadata: metadata) + } + + // MARK: - Session & Prompt Building + + /// Creates a language model session configured for alt text generation. + /// + /// - Returns: Configured session with instructions + public func makeSession() -> LanguageModelSession { + LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: Self.instructions + ) + } + + /// Instructions for the language model on how to generate alt text. + public static var instructions: String { + """ + You are helping a WordPress user generate alt text for an image. + Alt text should be descriptive and accessible for screen readers. + + **Parameters** + - IMAGE_ANALYSIS: Structured JSON with comprehensive visual analysis (MOST IMPORTANT) + The JSON includes: sceneClassification, faces (with position, size, features), humans, animals, + text content, orientation, regions of interest, barcodes, and document detection + - FILENAME: the image filename + - FILE_TYPE: the file type/extension + - DIMENSIONS: the image dimensions + - TITLE: the image title (if available) + - CAPTION: the image caption (if available) + - DESCRIPTION: the image description (if available) + + **Requirements** + - For simple images: 1-2 sentences describing the main subject and action + - For complex images (charts, infographics, screenshots): 2-3 sentences explaining key information + - Parse the JSON IMAGE_ANALYSIS to understand: + * Scene/subject: Use sceneClassification labels with highest confidence + * People: Check faces/humans data for count, position (left/center/right), and shot type (closeup/medium/distant) + * Spatial layout: Use position and orientation data to describe composition + * Text: If text is prominent, include key text content verbatim + * Documents/Screenshots: Mention if containsDocument is true + - Prioritize information based on: + 1. Primary subject (faces, humans, animals, main scene) + 2. Actions or relationships between subjects + 3. Setting/context from scene classification + 4. Important text content (if present) + - Use specific, concrete descriptions based on the data + - Use simple, clear language + - Do not include "image of", "picture of", or "photo of" + - Do not describe decorative or insignificant details + - For portraits: Include shot type (closeup/medium) and position if relevant + - For screenshots: Mention it's a screenshot and describe the key visible element + - For images with text: Include the most important text content + + **Examples** + Good: "Person smiling in closeup portrait with outdoor background" + Good: "Three people standing left to right in conference room" + Good: "Screenshot of WordPress editor with Publish button highlighted" + Good: "Bar chart showing 45% increase in website traffic during Q3" + Bad: "A person" (too vague, missing details from analysis) + Bad: "Image of a chart" (avoid "image of", describe what the chart shows) + + Only output the alt text, nothing else. + """ + } + + /// Builds the prompt for generating alt text. + /// + /// - Parameter metadata: The media metadata + /// - Returns: Formatted prompt string ready for the language model + public func makePrompt(metadata: MediaMetadata) -> String { + var contextParts: [String] = [] + + if let imageAnalysis = metadata.imageAnalysis, !imageAnalysis.isEmpty { + contextParts.append("IMAGE_ANALYSIS: '\(imageAnalysis)'") + } + if let filename = metadata.filename, !filename.isEmpty { + contextParts.append("FILENAME: '\(filename)'") + } + if let fileType = metadata.fileType, !fileType.isEmpty { + contextParts.append("FILE_TYPE: '\(fileType)'") + } + if let dimensions = metadata.dimensions, !dimensions.isEmpty { + contextParts.append("DIMENSIONS: '\(dimensions)'") + } + if let title = metadata.title, !title.isEmpty { + contextParts.append("TITLE: '\(title)'") + } + if let caption = metadata.caption, !caption.isEmpty { + contextParts.append("CAPTION: '\(caption)'") + } + if let description = metadata.description, !description.isEmpty { + contextParts.append("DESCRIPTION: '\(description)'") + } + + return """ + Generate alt text for an image with the following information: + + \(contextParts.joined(separator: "\n")) + """ + } +} + +@available(iOS 26, *) +extension IntelligenceService { + /// Generates alt text for a media item based on available metadata. + /// + /// - Parameter metadata: The media metadata to use for generation + /// - Returns: Generated alt text + /// - Throws: If metadata is insufficient or generation fails + public func generateAltText(metadata: MediaMetadata) async throws -> String { + try await ImageAltTextGenerator().generate(metadata: metadata) + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/ImageCaptionGenerator.swift b/Modules/Sources/WordPressIntelligence/UseCases/ImageCaptionGenerator.swift new file mode 100644 index 000000000000..61881b0638a1 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/ImageCaptionGenerator.swift @@ -0,0 +1,195 @@ +import Foundation +import FoundationModels +import UIKit +import WordPressShared + +/// Caption generation for media items. +/// +/// Generates engaging, informative captions for images based on +/// visual analysis and available metadata. +/// +/// Example usage: +/// ```swift +/// let generator = ImageCaptionGenerator() +/// let caption = try await generator.generate(metadata: metadata) +/// ``` +@available(iOS 26, *) +public struct ImageCaptionGenerator { + public var options: GenerationOptions + + public init(options: GenerationOptions = GenerationOptions(temperature: 0.8)) { + self.options = options + } + + /// Generates a caption for a media item. + /// + /// - Parameter metadata: The media metadata to use for generation + /// - Returns: Generated caption + /// - Throws: If metadata is insufficient or generation fails + public func generate(metadata: MediaMetadata) async throws -> String { + guard metadata.hasContent else { + throw NSError(domain: "IntelligenceService", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Insufficient metadata to generate caption. Please add a filename, title, or description first." + ]) + } + + let startTime = CFAbsoluteTimeGetCurrent() + let session = makeSession() + let prompt = makePrompt(metadata: metadata) + + let response = try await session.respond(to: prompt, options: options) + + WPLogInfo("ImageCaptionGenerator executed in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") + + return response.content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Generates a caption for an image with automatic Vision analysis. + /// + /// This convenience method automatically analyzes the image using Vision framework + /// and generates a caption based on the analysis combined with provided metadata. + /// + /// - Parameters: + /// - cgImage: The image to analyze and generate caption for + /// - metadata: Additional metadata (filename, title, etc.). The imageAnalysis field will be populated automatically. + /// - Returns: Generated caption + /// - Throws: If image analysis or generation fails + public func generate(cgImage: CGImage, metadata: MediaMetadata = MediaMetadata()) async throws -> String { + let imageAnalysis = try await IntelligenceService.analyzeImage(cgImage) + + let metadataWithAnalysis = MediaMetadata( + filename: metadata.filename, + title: metadata.title, + caption: metadata.caption, + description: metadata.description, + altText: metadata.altText, + fileType: metadata.fileType, + dimensions: metadata.dimensions, + imageAnalysis: imageAnalysis + ) + + return try await generate(metadata: metadataWithAnalysis) + } + + /// Generates a caption for an image with automatic Vision analysis. + /// + /// This convenience method automatically analyzes the image using Vision framework + /// and generates a caption based on the analysis combined with provided metadata. + /// + /// - Parameters: + /// - image: The UIImage to analyze and generate caption for + /// - metadata: Additional metadata (filename, title, etc.). The imageAnalysis field will be populated automatically. + /// - Returns: Generated caption + /// - Throws: If the image cannot be converted to CGImage, or if analysis/generation fails + public func generate(image: UIImage, metadata: MediaMetadata = MediaMetadata()) async throws -> String { + guard let cgImage = image.cgImage else { + throw NSError(domain: "IntelligenceService", code: -2, userInfo: [ + NSLocalizedDescriptionKey: "Unable to convert UIImage to CGImage" + ]) + } + return try await generate(cgImage: cgImage, metadata: metadata) + } + + /// Generates a caption for image data with automatic VisionKit analysis. + /// + /// This convenience method automatically analyzes the image using VisionKit + /// and generates a caption based on the analysis combined with provided metadata. + /// + /// - Parameters: + /// - imageData: The image data to analyze and generate caption for + /// - metadata: Additional metadata (filename, title, etc.). The imageAnalysis field will be populated automatically. + /// - Returns: Generated caption + /// - Throws: If the data cannot be converted to an image, or if analysis/generation fails + public func generate(imageData: Data, metadata: MediaMetadata = MediaMetadata()) async throws -> String { + guard let image = UIImage(data: imageData) else { + throw NSError(domain: "IntelligenceService", code: -3, userInfo: [ + NSLocalizedDescriptionKey: "Unable to create UIImage from data" + ]) + } + return try await generate(image: image, metadata: metadata) + } + + // MARK: - Session & Prompt Building + + /// Creates a language model session configured for caption generation. + /// + /// - Returns: Configured session with instructions + public func makeSession() -> LanguageModelSession { + LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: Self.instructions + ) + } + + /// Instructions for the language model on how to generate captions. + public static var instructions: String { + """ + You are helping a WordPress user generate a caption for an image. + Captions should be engaging, informative, and complement the image. + + **Parameters** + - IMAGE_ANALYSIS: Visual analysis of the actual image content (MOST IMPORTANT) + - FILENAME: the image filename + - FILE_TYPE: the file type/extension + - DIMENSIONS: the image dimensions + - TITLE: the image title (if available) + - ALT_TEXT: the image alt text (if available) + - DESCRIPTION: the image description (if available) + + **Requirements** + - Generate an engaging caption (1-2 sentences) + - Prioritize IMAGE_ANALYSIS to understand what's actually in the image + - Can be more creative and conversational than alt text + - May include context, emotion, or storytelling elements + - Only output the caption, nothing else + """ + } + + /// Builds the prompt for generating captions. + /// + /// - Parameter metadata: The media metadata + /// - Returns: Formatted prompt string ready for the language model + public func makePrompt(metadata: MediaMetadata) -> String { + var contextParts: [String] = [] + + if let imageAnalysis = metadata.imageAnalysis, !imageAnalysis.isEmpty { + contextParts.append("IMAGE_ANALYSIS: '\(imageAnalysis)'") + } + if let filename = metadata.filename, !filename.isEmpty { + contextParts.append("FILENAME: '\(filename)'") + } + if let fileType = metadata.fileType, !fileType.isEmpty { + contextParts.append("FILE_TYPE: '\(fileType)'") + } + if let dimensions = metadata.dimensions, !dimensions.isEmpty { + contextParts.append("DIMENSIONS: '\(dimensions)'") + } + if let title = metadata.title, !title.isEmpty { + contextParts.append("TITLE: '\(title)'") + } + if let altText = metadata.altText, !altText.isEmpty { + contextParts.append("ALT_TEXT: '\(altText)'") + } + if let description = metadata.description, !description.isEmpty { + contextParts.append("DESCRIPTION: '\(description)'") + } + + return """ + Generate a caption for an image with the following information: + + \(contextParts.joined(separator: "\n")) + """ + } +} + +@available(iOS 26, *) +extension IntelligenceService { + /// Generates a caption for a media item based on available metadata. + /// + /// - Parameter metadata: The media metadata to use for generation + /// - Returns: Generated caption + /// - Throws: If metadata is insufficient or generation fails + public func generateCaption(metadata: MediaMetadata) async throws -> String { + try await ImageCaptionGenerator().generate(metadata: metadata) + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/PostExcerptGenerator.swift b/Modules/Sources/WordPressIntelligence/UseCases/PostExcerptGenerator.swift new file mode 100644 index 000000000000..3fdceaa7b562 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/PostExcerptGenerator.swift @@ -0,0 +1,109 @@ +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 PostExcerptGenerator { + 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 prompt = await makePrompt(content: content) + let response = try await makeSession().respond( + to: prompt, + 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 { + """ + You are helping a WordPress user generate an excerpt for their post or page. + + **Parameters** + - POST_CONTENT: post contents (HTML or plain text) + - TARGET_LANGUAGE: detected language code (e.g., "en", "es", "fr", "ja") + - TARGET_LENGTH: sentence count (primary) and word range (secondary) + - GENERATION_STYLE: writing style to apply + + \(IntelligenceService.makeLocaleInstructions()) + + **Requirements** + 1. ⚠️ LANGUAGE: Match TARGET_LANGUAGE code if provided, otherwise match POST_CONTENT language. Never translate or default to English. + + 2. ⚠️ LENGTH: Match TARGET_LENGTH sentence count, stay within word range. Write complete sentences only. + + 3. ⚠️ STYLE: Follow GENERATION_STYLE exactly. + + **Best Practices** + - Capture the post's main value proposition + - Use active voice and strategic keywords naturally + - Don't duplicate the opening paragraph + - Work as standalone copy for search results, social media, and email + """ + } + + /// Creates a prompt for this excerpt configuration. + /// + /// This method handles content extraction (removing HTML, limiting size) and language detection + /// automatically before creating the prompt. + /// + /// - Parameter content: The raw post content (may include HTML) + /// - Returns: The formatted prompt ready for the language model + public func makePrompt(content: String) async -> String { + let extractedContent = IntelligenceService.extractRelevantText(from: content) + let language = IntelligenceService.detectLanguage(from: extractedContent) + let languageInstruction = language.map { "TARGET_LANGUAGE: \($0)\n" } ?? "" + + return """ + Generate EXACTLY 3 different excerpts for the given post. + + \(languageInstruction)TARGET_LENGTH: \(length.promptModifier) + CRITICAL: Write \(length.sentenceRange.lowerBound)-\(length.sentenceRange.upperBound) complete sentences. Stay within \(length.wordRange.lowerBound)-\(length.wordRange.upperBound) words. + + GENERATION_STYLE: \(style.promptModifier) + + POST_CONTENT: + \(extractedContent) + """ + } + + /// Prompt for generating additional excerpt options. + public static var loadMorePrompt: String { + "Generate 3 additional excerpts following the same TARGET_LENGTH and GENERATION_STYLE requirements" + } + + // MARK: - Result Type + + @Generable + public struct Result { + @Guide(description: "Suggested post excerpts", .count(3)) + public var excerpts: [String] + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/PostSummaryGenerator.swift b/Modules/Sources/WordPressIntelligence/UseCases/PostSummaryGenerator.swift new file mode 100644 index 000000000000..363efa772c52 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/PostSummaryGenerator.swift @@ -0,0 +1,94 @@ +import Foundation +import FoundationModels + +/// 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. +/// +/// Example usage: +/// ```swift +/// let summary = PostSummary() +/// let result = try await summary.generate(content: postContent) +/// ``` +@available(iOS 26, *) +public struct PostSummaryGenerator { + public var options: GenerationOptions + + public init(options: GenerationOptions = GenerationOptions(temperature: 0.3)) { + self.options = options + } + + /// Generate a summary for the given post content. + /// + /// - Parameter content: The post content to summarize (HTML or plain text) + /// - Returns: A concise summary in the same language as the source + /// - Throws: If the language model session fails + public func generate(content: String) async throws -> String { + let session = makeSession() + let prompt = await makePrompt(content: content) + return try await session.respond(to: prompt).content + } + + /// Creates a language model session configured for post summarization. + /// + /// - Returns: Configured session with instructions + public func makeSession() -> LanguageModelSession { + LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: Self.instructions + ) + } + + /// Instructions for the language model on how to generate summaries. + public static var instructions: String { + """ + 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. + + **Prompt Parameters** + - POST_CONTENT: contents of the post (HTML or plain text) + - TARGET_LANGUAGE: the detected language code of POST_CONTENT (e.g., "en", "es", "fr", "ja") when available + + \(IntelligenceService.makeLocaleInstructions()) + + **CRITICAL Requirement** + ⚠️ LANGUAGE: Generate summary in the language specified by TARGET_LANGUAGE code if provided, otherwise match POST_CONTENT language exactly. NO translation. NO defaulting to English. Match input language EXACTLY. + + Do not include anything other than the summary in the response. + """ + } + + /// Builds the prompt for summarizing post content. + /// + /// This method handles content extraction (removing HTML, limiting size) and language detection + /// automatically before creating the prompt. + /// + /// - Parameter content: The raw post content (may include HTML) + /// - Returns: Formatted prompt string ready for the language model + public func makePrompt(content: String) async -> String { + let extractedContent = IntelligenceService.extractRelevantText(from: content, ratio: 0.8) + let language = IntelligenceService.detectLanguage(from: extractedContent) + let languageInstruction = language.map { "TARGET_LANGUAGE: \($0)\n\n" } ?? "" + + return """ + Summarize the following post: + + \(languageInstruction)POST_CONTENT: + \(extractedContent) + """ + } +} + +@available(iOS 26, *) +extension IntelligenceService { + /// Post summarization for WordPress content. + /// + /// - Parameter content: The post content to summarize + /// - Returns: A concise summary + /// - Throws: If summarization fails + public func summarize(content: String) async throws -> String { + try await PostSummaryGenerator().generate(content: content) + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummaryGenerator.swift b/Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummaryGenerator.swift new file mode 100644 index 000000000000..1c3865cda358 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummaryGenerator.swift @@ -0,0 +1,44 @@ +import Foundation +import FoundationModels + +/// Support ticket summarization. +/// +/// Generates short, concise titles (fewer than 10 words) for support +/// conversations based on the opening message. +@available(iOS 26, *) +public enum SupportTicketSummaryGenerator { + public 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/TagSuggestionGenerator.swift b/Modules/Sources/WordPressIntelligence/UseCases/TagSuggestionGenerator.swift new file mode 100644 index 000000000000..7ece2009e845 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/TagSuggestionGenerator.swift @@ -0,0 +1,130 @@ +import Foundation +import FoundationModels +import WordPressShared + +/// 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. +@available(iOS 26, *) +public struct TagSuggestionGenerator { + public var options: GenerationOptions + + public init(options: GenerationOptions = GenerationOptions(temperature: 0.2)) { + self.options = options + } + + /// Generates tags for a WordPress post. + public func generate(post: String, siteTags: [String] = [], postTags: [String] = []) async throws -> [String] { + let startTime = CFAbsoluteTimeGetCurrent() + + let prompt = await makePrompt(post: post, siteTags: siteTags, postTags: postTags) + let response = try await makeSession().respond( + to: prompt, + generating: Result.self, + options: options + ) + + WPLogInfo("TagSuggestion executed in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") + + let existingPostTags = Set(postTags) + return response.content.tags + .deduplicated() + .filter { !existingPostTags.contains($0) } + } + + /// Creates a language model session configured for tag suggestion. + public func makeSession() -> LanguageModelSession { + LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: Self.instructions + ) + } + + /// Instructions for the language model session. + public static var instructions: String { + """ + 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 + - TARGET_LANGUAGE: the detected language code for tag generation (e.g., "en", "es", "fr", "ja") when available + + \(IntelligenceService.makeLocaleInstructions()) + + **Steps** + - 1. Identify the specific formatting pattern used (e.g., lowercase with underscores, capitalized words with spaces, etc) + - 2. Generate a list of relevant suggested tags based on POST_CONTENT and SITE_TAGS relevant to the content. + + **CRITICAL Requirements** + - ⚠️ LANGUAGE: Generate tags in the language specified by TARGET_LANGUAGE code if provided. If SITE_TAGS exist, match their language. Otherwise match POST_CONTENT language. NO translation. NO defaulting to English. + - Tags MUST match the formatting pattern of existing SITE_TAGS (capitalization, separators, etc) + - 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 tags + """ + } + + /// Creates a prompt for tag suggestion with the given parameters. + /// + /// This method handles content extraction and language detection automatically. + /// Language is detected from site tags (if available) or post content, with site tags taking priority. + /// + /// - Parameters: + /// - post: The raw post content (may include HTML) + /// - siteTags: Existing tags from the site + /// - postTags: Tags already added to this post + /// - Returns: Formatted prompt string ready for the language model + public func makePrompt(post: String, siteTags: [String], postTags: [String]) async -> String { + // Limit siteTags and content size to respect context window + let siteTags = siteTags.prefix(50) + let extractedPost = IntelligenceService.extractRelevantText(from: post) + + // Detect language: prioritize site tags language, fallback to post content + let language: String? = { + if !siteTags.isEmpty { + let siteTagsText = siteTags.joined(separator: " ") + if let tagLanguage = IntelligenceService.detectLanguage(from: siteTagsText) { + return tagLanguage + } + } + return IntelligenceService.detectLanguage(from: extractedPost) + }() + + let languageInstruction = language.map { "TARGET_LANGUAGE: \($0)\n\n" } ?? "" + + return """ + Suggest tags for a post. + + \(languageInstruction)POST_CONTENT: ''' + \(extractedPost) + ''' + + SITE_TAGS: '\(siteTags.joined(separator: ", "))' + + EXISTING_POST_TAGS: '\(postTags.joined(separator: ", "))' + """ + } + + /// Prompt for generating additional tag suggestions. + public static var loadMorePrompt: String { + "Generate additional relevant tags following the same format and language requirements" + } + + // MARK: - Result Type + + @Generable + public struct Result { + @Guide(description: "Newly generated tags following the identified format", .count(5...10)) + public 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/Helpers/ExcerptTestOutput.swift b/Modules/Tests/WordPressIntelligenceTests/Helpers/ExcerptTestOutput.swift new file mode 100644 index 000000000000..8f89103ccdcd --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/Helpers/ExcerptTestOutput.swift @@ -0,0 +1,63 @@ +import Foundation +import NaturalLanguage + +/// Structured output for excerpt tests that can be consumed by evaluation scripts. +struct ExcerptTestOutput: Codable { + let testName: String + let language: String + let length: String + let style: String + let originalContent: String + let excerpts: [String] + let duration: Double + let timestamp: String + + /// Convenience initializer that accepts ExcerptTestCaseParameters and Duration. + init( + parameters: ExcerptTestCaseParameters, + excerpts: [String], + duration: Duration + ) { + self.testName = parameters.testDescription + self.language = parameters.data.languageCode.rawValue + self.length = parameters.length.displayName + self.style = parameters.style.displayName + self.originalContent = parameters.data.content + self.excerpts = excerpts + self.duration = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + self.timestamp = ISO8601DateFormatter().string(from: Date()) + } + + /// Record test output to console for extraction and print formatted results. + /// Emits base64-encoded JSON between markers for reliable parsing. + /// This output can be extracted and evaluated by external tools. + func recordAndPrint(parameters: ExcerptTestCaseParameters, duration: Duration) throws { + // Always record structured output for evaluation script + try record() + + // Print formatted results for readability + TestHelpers.printExcerptResults( + parameters: parameters, + excerpts: excerpts, + duration: duration + ) + } + + /// Record test output to console for extraction. + /// Emits base64-encoded JSON between markers for reliable parsing. + /// This output can be extracted and evaluated by external tools. + private func record() throws { + // Encode to JSON + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let jsonData = try encoder.encode(self) + + // Base64 encode for safe console transmission + let base64String = jsonData.base64EncodedString() + + // Emit structured output with markers + print("__EXCERPT_OUTPUT_START__") + print(base64String) + print("__EXCERPT_OUTPUT_END__") + } +} diff --git a/Modules/Tests/WordPressIntelligenceTests/Helpers/SummaryTestOutput.swift b/Modules/Tests/WordPressIntelligenceTests/Helpers/SummaryTestOutput.swift new file mode 100644 index 000000000000..3b0130dc7b93 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/Helpers/SummaryTestOutput.swift @@ -0,0 +1,61 @@ +import Foundation +import NaturalLanguage + +/// Structured output for post summary tests that can be consumed by evaluation scripts. +struct SummaryTestOutput: Codable { + let testType: String + let testName: String + let language: String + let originalContent: String + let summary: String + let duration: Double + let timestamp: String + + /// Convenience initializer that accepts SummaryTestCaseParameters and Duration. + init( + parameters: SummaryTestCaseParameters, + summary: String, + duration: Duration + ) { + self.testType = "post-summary" + self.testName = parameters.testDescription + self.language = parameters.data.languageCode.rawValue + self.originalContent = parameters.data.content + self.summary = summary + self.duration = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + self.timestamp = ISO8601DateFormatter().string(from: Date()) + } + + /// Record test output to console for extraction and print formatted results. + /// Emits base64-encoded JSON between markers for reliable parsing. + /// This output can be extracted and evaluated by external tools. + func recordAndPrint(parameters: SummaryTestCaseParameters, duration: Duration) throws { + // Always record structured output for evaluation script + try record() + + // Print formatted results for readability + TestHelpers.printSummaryResults( + parameters: parameters, + summary: summary, + duration: duration + ) + } + + /// Record test output to console for extraction. + /// Emits base64-encoded JSON between markers for reliable parsing. + /// This output can be extracted and evaluated by external tools. + private func record() throws { + // Encode to JSON + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let jsonData = try encoder.encode(self) + + // Base64 encode for safe console transmission + let base64String = jsonData.base64EncodedString() + + // Emit structured output with markers + print("__SUMMARY_OUTPUT_START__") + print(base64String) + print("__SUMMARY_OUTPUT_END__") + } +} diff --git a/Modules/Tests/WordPressIntelligenceTests/Helpers/TagTestOutput.swift b/Modules/Tests/WordPressIntelligenceTests/Helpers/TagTestOutput.swift new file mode 100644 index 000000000000..9945539d6d74 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/Helpers/TagTestOutput.swift @@ -0,0 +1,65 @@ +import Foundation +import NaturalLanguage + +/// Structured output for tag suggestion tests that can be consumed by evaluation scripts. +struct TagTestOutput: Codable { + let testType: String + let testName: String + let language: String + let originalContent: String + let siteTags: [String] + let existingPostTags: [String] + let tags: [String] + let duration: Double + let timestamp: String + + /// Convenience initializer that accepts TagTestCaseParameters and Duration. + init( + parameters: TagTestCaseParameters, + tags: [String], + duration: Duration + ) { + self.testType = "tag-suggestion" + self.testName = parameters.testDescription + self.language = parameters.data.languageCode.rawValue + self.originalContent = parameters.data.content + self.siteTags = parameters.siteTags + self.existingPostTags = parameters.postTags + self.tags = tags + self.duration = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + self.timestamp = ISO8601DateFormatter().string(from: Date()) + } + + /// Record test output to console for extraction and print formatted results. + /// Emits base64-encoded JSON between markers for reliable parsing. + /// This output can be extracted and evaluated by external tools. + func recordAndPrint(parameters: TagTestCaseParameters, duration: Duration) throws { + // Always record structured output for evaluation script + try record() + + // Print formatted results for readability + TestHelpers.printTagResults( + parameters: parameters, + tags: tags, + duration: duration + ) + } + + /// Record test output to console for extraction. + /// Emits base64-encoded JSON between markers for reliable parsing. + /// This output can be extracted and evaluated by external tools. + private func record() throws { + // Encode to JSON + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let jsonData = try encoder.encode(self) + + // Base64 encode for safe console transmission + let base64String = jsonData.base64EncodedString() + + // Emit structured output with markers + print("__TAG_OUTPUT_START__") + print(base64String) + print("__TAG_OUTPUT_END__") + } +} diff --git a/Modules/Tests/WordPressIntelligenceTests/Helpers/TestData.swift b/Modules/Tests/WordPressIntelligenceTests/Helpers/TestData.swift new file mode 100644 index 000000000000..82e23a3d1b72 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/Helpers/TestData.swift @@ -0,0 +1,450 @@ +import Foundation +import NaturalLanguage + +/// Test content with title and body for intelligence service tests. +struct TestContent { + let title: String + let content: String + let languageCode: NLLanguage +} + +/// 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: - English Content + + static let englishPostWithHTML = TestContent( + title: "English Post with HTML", + content: """ + +

The Art of Sourdough Bread Making

+ + + +

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 that make it worth the extra effort.

+ + + +

Essential Ingredients

+ + + +
    +
  • Active sourdough starter
  • +
  • 500g bread flour
  • +
  • 350ml filtered water
  • +
  • 10g sea salt
  • +
  • Optional: seeds or grains for texture
  • +
+ + + +

The key to successful sourdough lies in maintaining a healthy starter culture and + understanding the fermentation process. Temperature and timing are crucial factors that + will determine the final texture and flavor of your bread.

+ + """, + languageCode: .english + ) + + static let veryShortEnglishContent = TestContent( + title: "Very Short English Content", + content: "Artificial intelligence is transforming our world in unprecedented ways.", + languageCode: .english + ) + + // MARK: - Spanish Content + + 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. + """, + languageCode: .spanish + ) + + 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. + """, + languageCode: .spanish + ) + + // 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. + """, + languageCode: .english + ) + + 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. + """, + languageCode: .english + ) + + 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. + """, + languageCode: .english + ) + + 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. + """, + languageCode: .english + ) + + 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. + """, + languageCode: .english + ) + + // 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. + """, + languageCode: .french + ) + + // MARK: - Japanese Content + + static let japanesePost = TestContent( + title: "Japanese Post", + content: """ + 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 + この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 + + 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 + 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 + + 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 + インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 + """, + languageCode: .japanese + ) + + // 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. + """, + languageCode: .german + ) + + // MARK: - Mandarin Content + + static let mandarinPost = TestContent( + title: "Mandarin Post", + content: """ + 中国茶文化有着数千年的悠久历史,是中华文明的重要组成部分。从绿茶到红茶, + 从乌龙茶到普洱茶,每一种茶都有其独特的制作工艺和品鉴方法。 + + 茶道不仅仅是一种饮茶的方式,更是一种生活态度和精神追求。通过泡茶、品茶的过程, + 人们可以修身养性,体会宁静致远的境界。 + + 好的茶叶需要适宜的水温和冲泡时间。绿茶适合用80度左右的水温,而红茶则需要 + 95度以上的沸水。掌握这些细节,才能充分释放茶叶的香气和味道。 + """, + languageCode: .simplifiedChinese + ) + + // MARK: - Hindi Content + + static let hindiPost = TestContent( + title: "Hindi Post", + content: """ + योग भारतीय संस्कृति की एक प्राचीन परंपरा है जो शारीरिक, मानसिक और आध्यात्मिक स्वास्थ्य को बढ़ावा देती है। + आसन, प्राणायाम और ध्यान के माध्यम से, योग हमें संतुलित और स्वस्थ जीवन जीने में मदद करता है। + + नियमित योग अभ्यास से तनाव कम होता है, मांसपेशियां मजबूत होती हैं, और मन शांत रहता है। + सूर्य नमस्कार, शवासन, और पद्मासन जैसे आसन शुरुआती लोगों के लिए बहुत उपयोगी हैं। + + योग केवल व्यायाम नहीं है, बल्कि यह जीवन जीने की एक कला है। प्रतिदिन कुछ मिनट योग करने से + जीवन की गुणवत्ता में उल्लेखनीय सुधार हो सकता है। + """, + languageCode: .hindi + ) + + // MARK: - Russian Content + + static let russianPost = TestContent( + title: "Russian Post", + content: """ + Русская литература золотого века подарила миру величайшие произведения, которые + продолжают вдохновлять читателей по всему свету. Толстой, Достоевский, Чехов и + Пушкин создали произведения, исследующие глубины человеческой души. + + Эти авторы не просто рассказывали истории, они поднимали фундаментальные вопросы + о смысле жизни, морали, и человеческой природе. Их произведения остаются актуальными + и сегодня, предлагая читателям глубокие размышления о вечных темах. + + Чтение классической русской литературы — это путешествие в мир сложных характеров, + философских идей и богатого культурного наследия. Каждое произведение открывает + новые горизонты понимания человеческого опыта. + """, + languageCode: .russian + ) + + // 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. + """, + languageCode: .english + ) + + // MARK: - Error Handling Test Cases + + static let emptyContent = TestContent( + title: "Empty Content", + content: "", + languageCode: .english + ) + + static let veryLongContent = TestContent( + title: "Very Long Content", + content: String(repeating: """ + Quantum computing represents a paradigm shift in computational technology. Unlike classical + computers that process information using bits (0s and 1s), quantum computers leverage the + principles of quantum mechanics to operate with qubits. These qubits can exist in multiple + states simultaneously through superposition, enabling parallel processing of vast amounts + of data. The phenomenon of quantum entanglement further enhances computational capabilities + by allowing qubits to be correlated in ways that classical bits cannot achieve. + + The implications of quantum computing extend across numerous fields. In cryptography, quantum + computers pose both a threat to current encryption methods and a promise for ultra-secure + quantum key distribution. Drug discovery and molecular modeling benefit from quantum simulation + of complex chemical interactions. Financial modeling, optimization problems, and artificial + intelligence are all domains poised for transformation through quantum algorithms. + + However, significant challenges remain before quantum computing becomes mainstream. Quantum + systems are extremely sensitive to environmental interference, requiring near-absolute-zero + temperatures and electromagnetic isolation. Quantum decoherence occurs when qubits lose their + quantum properties due to external disturbances, limiting the duration of quantum computations. + Error correction in quantum systems is fundamentally more complex than in classical computing, + requiring multiple physical qubits to encode a single logical qubit. + + Current quantum computers are in the NISQ era (Noisy Intermediate-Scale Quantum), characterized + by systems with 50-100 qubits that are prone to errors. Major technology companies and research + institutions are racing to achieve quantum advantage—the point where quantum computers can + solve practical problems faster than classical supercomputers. Google's quantum processor + achieved a milestone in 2019 by performing a specific calculation in 200 seconds that would + take the world's fastest supercomputer 10,000 years. + + """, count: 30) + "\n\nThis content continues for over 10,000 words to test handling of very long inputs.", + languageCode: .english + ) + + static let malformedHTML = TestContent( + title: "Malformed HTML", + content: """ +

Broken HTML Content

+

This paragraph is not closed properly +

This div has no closing tag +
    +
  • Item 1 +
  • Item 2
  • +
  • Item 3
  • +
+

Bold text with nested italics

+ + Missing closing bracket
+        <a href=Link with no closing tag + """, + languageCode: .english + ) + + static let emojiAndSpecialCharacters = TestContent( + title: "Emoji and Special Characters", + content: """ + 🌟 Welcome to the World of Unicode! 🌍 + + Emojis have become an integral part of digital communication 💬. From simple smileys 😊 + to complex sequences 👨‍👩‍👧‍👦, they convey emotions and ideas across language barriers. + + Special characters matter too: © ® ™ § ¶ † ‡ • ◦ ‣ ⁃ ⁎ ⁕ ❖ ※ + Mathematical symbols: ∑ ∏ √ ∞ ≈ ≠ ≤ ≥ ± × ÷ ∂ ∫ ∇ + Currency symbols: $ € £ ¥ ₹ ₽ ₩ ₪ ฿ ¢ + + Zero-width characters and combining marks: café vs café (different é construction) + Right-to-left marks: ‏עברית‏ العربية + Emoji variations: 👍 👍🏻 👍🏼 👍🏽 👍🏾 👍🏿 + + Uncommon Unicode: Ω ℃ ℉ № ℠ ™ ℮ ⅓ ⅔ ¼ ¾ ⅛ ⅜ ⅝ ⅞ + Box drawing: ┌─┬─┐ │ │ │ ├─┼─┤ │ │ │ └─┴─┘ + + This tests how the system handles diverse Unicode characters! 🎉✨🚀 + """, + languageCode: .english + ) + + // 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 = [ + "日本料理", + "レシピ", + "料理", + "伝統", + "和食" + ] + + static let germanSiteTags = [ + "technologie", + "innovation", + "deutschland", + "automobil", + "elektromobilität" + ] + + static let mandarinSiteTags = [ + "文化", + "茶道", + "传统", + "生活方式", + "健康" + ] + + static let russianSiteTags = [ + "литература", + "культура", + "классика", + "искусство", + "философия" + ] +} diff --git a/Modules/Tests/WordPressIntelligenceTests/Helpers/TestHelperWordCountTests.swift b/Modules/Tests/WordPressIntelligenceTests/Helpers/TestHelperWordCountTests.swift new file mode 100644 index 000000000000..cdcc796e07fd --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/Helpers/TestHelperWordCountTests.swift @@ -0,0 +1,89 @@ +import Testing +import NaturalLanguage + +@Suite("Word Counting") +struct TestHelperWordCountTests { + + @Test("English word counting") + func englishWordCounting() { + let text = "The quick brown fox jumps over the lazy dog" + let count = TestHelpers.countWords(text, language: .english) + #expect(count == 9) + } + + @Test("Spanish word counting") + func spanishWordCounting() { + let text = "El rápido zorro marrón salta sobre el perro perezoso" + let count = TestHelpers.countWords(text, language: .spanish) + #expect(count == 9) + } + + @Test("Japanese word counting") + func japaneseWordCounting() { + // "I like Japanese food very much" - 5 meaningful word units + let text = "私は日本料理が大好きです" + let count = TestHelpers.countWords(text, language: .japanese) + + // NLTokenizer properly segments Japanese into word units + // Should recognize: 私/は/日本料理/が/大好き/です + #expect(count >= 5 && count <= 7, "Expected 5-7 words, got \(count)") + } + + @Test("Mandarin word counting") + func mandarinWordCounting() { + // "I like Chinese tea culture" - approximately 6-8 word units + let text = "我喜欢中国茶文化" + let count = TestHelpers.countWords(text, language: .simplifiedChinese) + + // NLTokenizer segments: 我/喜欢/中国/茶/文化 + #expect(count >= 4 && count <= 8, "Expected 4-8 words, got \(count)") + } + + @Test("French word counting with punctuation") + func frenchWordCountingWithPunctuation() { + let text = "Bonjour! Comment allez-vous aujourd'hui?" + let count = TestHelpers.countWords(text, language: .french) + // "allez-vous" is correctly tokenized as 2 words (verb + pronoun) + #expect(count == 5) + } + + @Test("Empty text") + func emptyText() { + let count = TestHelpers.countWords("", language: .english) + #expect(count == 0) + } + + @Test("Single word") + func singleWord() { + let count = TestHelpers.countWords("Hello", language: .english) + #expect(count == 1) + } + + @Test("Text with extra whitespace") + func textWithWhitespace() { + let text = " Hello world " + let count = TestHelpers.countWords(text, language: .english) + #expect(count == 2) + } + + @Test("Mixed English and numbers") + func mixedContent() { + let text = "There are 3 apples and 5 oranges" + let count = TestHelpers.countWords(text, language: .english) + #expect(count == 7) + } + + @Test("German word counting with compounds") + func germanWordCounting() { + let text = "Die deutsche Automobilindustrie ist sehr wichtig" + let count = TestHelpers.countWords(text, language: .german) + #expect(count == 6) + } + + @Test("Russian word counting with Cyrillic") + func russianWordCounting() { + let text = "Русская литература очень богата и интересна" + let count = TestHelpers.countWords(text, language: .russian) + #expect(count == 6) + } +} diff --git a/Modules/Tests/WordPressIntelligenceTests/Helpers/TestHelpers.swift b/Modules/Tests/WordPressIntelligenceTests/Helpers/TestHelpers.swift new file mode 100644 index 000000000000..85a2f4ef0ba2 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/Helpers/TestHelpers.swift @@ -0,0 +1,622 @@ +import Foundation +import NaturalLanguage +import Testing +@testable import WordPressIntelligence + +/// 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, + expectedLanguage: NLLanguage, + duration: Duration + ) { + printSectionHeader(title) + + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + print("📝 Generated \(excerpts.count) variations (language: \(expectedLanguage.rawValue), time: \(String(format: "%.3f", durationSeconds))s)") + print() + + let boxWidth = 80 + + for (i, excerpt) in excerpts.enumerated() { + let wordCount = countWords(excerpt, language: expectedLanguage) + + let detectedLanguage = detectLanguage(excerpt) + let languageMatch = detectedLanguage == expectedLanguage + let languageIndicator = languageMatch ? "✓" : "✗" + let languageInfo = "\(languageIndicator) \(detectedLanguage?.rawValue ?? "unknown")" + + // Fixed-width header + let header = "Variation \(i + 1) (\(wordCount) words, \(languageInfo))" + let headerVisualWidth = visualLength(header) + let headerPadding = max(0, boxWidth - headerVisualWidth - 4) // -4 for "┌─ " + "┐" + print("┌─ \(header) " + String(repeating: "─", count: headerPadding) + "┐") + + // Content with consistent width and padding + // Use slightly smaller width for wrapping to avoid edge cases with emoji rendering + let innerWidth = boxWidth - 4 // -4 for "│ " and " │" + let wrapWidth = innerWidth - 2 // Be conservative to avoid overflow + + for line in excerpt.wrapped(width: wrapWidth).split(separator: "\n") { + let lineStr = String(line) + let lineVisualWidth = visualLength(lineStr) + let linePadding = max(0, innerWidth - lineVisualWidth) + print("│ \(lineStr)\(String(repeating: " ", count: linePadding)) │") + } + + // Fixed-width footer + print("└" + String(repeating: "─", count: boxWidth - 2) + "┘") + print() + } + } + + static func printExcerptResults( + parameters: ExcerptTestCaseParameters, + excerpts: [String], + duration: Duration + ) { + printExcerptResults( + parameters.testDescription, + excerpts: excerpts, + targetLength: parameters.length.promptModifier, + style: parameters.style.displayName, + expectedLanguage: parameters.data.languageCode, + duration: duration + ) + } + + @available(iOS 26, *) + static func printExcerptResults( + _ title: String, + excerpts: [String], + generator: PostExcerptGenerator, + expectedLanguage: NLLanguage, + duration: Duration + ) { + printExcerptResults( + title, + excerpts: excerpts, + targetLength: generator.length.promptModifier, + style: generator.style.displayName, + expectedLanguage: expectedLanguage, + duration: duration + ) + } + + // 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) { + let boxWidth = 80 + let border = String(repeating: "═", count: boxWidth) + + print() + print("╔\(border)╗") + + // Extract language from title (first word) + let language = title.split(separator: " ").first.map(String.init) + let flag = language.map { languageFlag(for: $0) } ?? "" + let displayTitle = flag.isEmpty ? title : "\(flag) \(title)" + + // Calculate padding (accounting for emoji visual width) + // Flag emojis are 2 unicode scalars but display as ~2 visual spaces + let visualWidth = visualLength(displayTitle) + let paddingNeeded = boxWidth - visualWidth - 2 // -2 for "║ " and " ║" + let paddedTitle = displayTitle + String(repeating: " ", count: max(0, paddingNeeded)) + + print("║ \(paddedTitle) ║") + print("╠\(border)╣") + print() + } + + private static func printSectionFooter() { + let boxWidth = 80 + let border = String(repeating: "═", count: boxWidth) + print("╚\(border)╝") + print() + } + + // MARK: - Performance Measurement + + /// Measures the execution time of an async throwing operation. + static func measure( + _ operation: () async throws -> T + ) async throws -> (result: T, duration: Duration) { + let clock = ContinuousClock() + let start = clock.now + let result = try await operation() + let duration = clock.now - start + return (result, duration) + } + + // MARK: - Language Detection + + /// Detects the dominant language in the given text. + static func detectLanguage(_ text: String) -> NLLanguage? { + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + return recognizer.dominantLanguage + } + + /// Verifies that the text matches the expected language. + static func verifyLanguage(_ text: String, matches expected: NLLanguage) -> Bool { + detectLanguage(text) == expected + } + + /// Verifies that all excerpts match the expected language. + static func verifyExcerptsLanguage(_ excerpts: [String], expectedLanguage: NLLanguage) { + for (index, excerpt) in excerpts.enumerated() { + let detectedLanguage = detectLanguage(excerpt) + + #expect( + detectedLanguage == expectedLanguage, + "Excerpt \(index + 1) language mismatch: expected \(expectedLanguage.rawValue), got \(detectedLanguage?.rawValue ?? "unknown")\nExcerpt: \(excerpt)" + ) + } + } + + // MARK: - Word Counting + + /// Counts words in text, properly handling all languages including CJK. + /// + /// Uses NLTokenizer for accurate word segmentation across different scripts: + /// - Space-separated languages (English, Spanish, French, etc.) + /// - CJK languages without spaces (Japanese, Mandarin) + /// - Mixed scripts + static func countWords(_ text: String, language: NLLanguage? = nil) -> Int { + guard !text.isEmpty else { return 0 } + + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = text + + if let language { + tokenizer.setLanguage(language) + } + + var wordCount = 0 + tokenizer.enumerateTokens(in: text.startIndex.. 150% of max (test fails) + /// - **Warning**: Word count slightly outside target range but within lenient bounds (test passes with warning) + /// - **Pass**: Word count within target range + /// + /// This approach accommodates LLM variance and language differences while catching egregious violations. + static func verifyExcerptsWordCount( + _ excerpts: [String], + wordRange: ClosedRange, + language: NLLanguage? = nil + ) { + // Lenient bounds: allow 50% below min, 200% above max before failing + let strictMinWords = Int(Double(wordRange.lowerBound) * 0.5) // 70% of minimum + let strictMaxWords = Int(Double(wordRange.upperBound) * 2.0) // 200% of maximum + + for (index, excerpt) in excerpts.enumerated() { + let wordCount = countWords(excerpt, language: language) + + // Check minimum word count + if wordCount < strictMinWords { + // FAIL: Way too short (< 70% of target minimum) + #expect( + wordCount >= strictMinWords, + "Excerpt \(index + 1) CRITICALLY SHORT: \(wordCount) words (target: \(wordRange.lowerBound)-\(wordRange.upperBound), minimum acceptable: \(strictMinWords))\nExcerpt: \(excerpt)" + ) + } else if wordCount < wordRange.lowerBound { + // WARNING: Below target but within acceptable bounds + Issue.record( + Comment(rawValue: "⚠️ Excerpt \(index + 1) slightly short: \(wordCount) words (target minimum: \(wordRange.lowerBound), acceptable minimum: \(strictMinWords))\nExcerpt: \(excerpt)") + ) + } + + // Check maximum word count + if wordCount > strictMaxWords { + // FAIL: Way too long (> 150% of target maximum) + #expect( + wordCount <= strictMaxWords, + "Excerpt \(index + 1) CRITICALLY LONG: \(wordCount) words (target: \(wordRange.lowerBound)-\(wordRange.upperBound), maximum acceptable: \(strictMaxWords))\nExcerpt: \(excerpt)" + ) + } else if wordCount > wordRange.upperBound { + // WARNING: Above target but within acceptable bounds + Issue.record( + Comment(rawValue: "⚠️ Excerpt \(index + 1) slightly long: \(wordCount) words (target maximum: \(wordRange.upperBound), acceptable maximum: \(strictMaxWords))\nExcerpt: \(excerpt)") + ) + } + } + } + + // MARK: - Excerpt Diversity + + /// Calculates Levenshtein distance between two strings. + /// + /// Levenshtein distance is the minimum number of single-character edits + /// (insertions, deletions, or substitutions) required to change one string into another. + /// + /// - Returns: The edit distance between the two strings + static func levenshteinDistance(_ s1: String, _ s2: String) -> Int { + let s1Array = Array(s1) + let s2Array = Array(s2) + let m = s1Array.count + let n = s2Array.count + + guard m > 0 else { return n } + guard n > 0 else { return m } + + var matrix = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) + + for i in 0...m { + matrix[i][0] = i + } + + for j in 0...n { + matrix[0][j] = j + } + + for i in 1...m { + for j in 1...n { + let cost = s1Array[i - 1] == s2Array[j - 1] ? 0 : 1 + matrix[i][j] = min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost // substitution + ) + } + } + + return matrix[m][n] + } + + /// Calculates similarity ratio between two strings (0.0 to 1.0). + /// + /// - Returns: 1.0 for identical strings, 0.0 for completely different strings + static func similarityRatio(_ s1: String, _ s2: String) -> Double { + let maxLength = max(s1.count, s2.count) + guard maxLength > 0 else { return 1.0 } + + let distance = levenshteinDistance(s1, s2) + return 1.0 - Double(distance) / Double(maxLength) + } + + /// Verifies that all excerpts are sufficiently different from each other. + /// + /// Checks all pairs of excerpts to ensure they have meaningful variation. + /// Uses Levenshtein distance to measure similarity. + /// + /// - Parameters: + /// - excerpts: The excerpts to compare + /// - minDifferenceRatio: Minimum required difference (0.0-1.0). Default 0.15 means excerpts must be at least 15% different + static func verifyExcerptsDiversity( + _ excerpts: [String], + minDifferenceRatio: Double = 0.15 + ) { + guard excerpts.count >= 2 else { return } + + for i in 0..= minDifferenceRatio, + """ + Excerpts \(i + 1) and \(j + 1) are too similar (\(String(format: "%.1f%%", similarity * 100)) similar, \ + need at least \(String(format: "%.1f%%", minDifferenceRatio * 100)) difference) + + Excerpt \(i + 1): \(excerpts[i]) + + Excerpt \(j + 1): \(excerpts[j]) + """ + ) + } + } + } + + 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 "mandarin": return "🇨🇳" + case "hindi": return "🇮🇳" + case "russian": return "🇷🇺" + case "mixed": return "🌐" + case "dominant": return "🌐" + default: return "🌍" + } + } + + /// Calculate visual length of string, accounting for emoji width. + /// Different emojis have different visual widths in terminals. + static func visualLength(_ string: String) -> Int { + var length = 0 + var skipNext = false + + for scalar in string.unicodeScalars { + if skipNext { + skipNext = false + continue + } + + // Regional indicator symbols (flag emojis) - they come in pairs + if (0x1F1E6...0x1F1FF).contains(scalar.value) { + length += 2 + skipNext = true // Skip the second regional indicator + } else if scalar.properties.isEmoji || scalar.properties.isEmojiPresentation { + // Simple emojis like ✓ often render as 1-2 spaces + // Being conservative: most emojis take 2 spaces + length += 2 + } else { + length += 1 + } + } + return length + } + + // MARK: - Tag Validation + + /// Verifies that all tags match the expected language. + /// + /// Detects language from all tags joined together for more reliable detection, + /// as individual tags may be too short. + static func verifyTagsLanguage(_ tags: [String], expectedLanguage: NLLanguage) { + guard !tags.isEmpty else { return } + + // Join all tags with spaces for more reliable language detection + let joinedTags = tags.joined(separator: " ") + let detectedLanguage = detectLanguage(joinedTags) + + #expect( + detectedLanguage == expectedLanguage, + "Tags language mismatch: expected \(expectedLanguage.rawValue), got \(detectedLanguage?.rawValue ?? "unknown")\nTags: \(tags.joined(separator: ", "))" + ) + } + + /// Verifies that tags follow the same format as site tags. + /// Checks for patterns like: lowercase-with-hyphens, lowercase_with_underscores, Title Case, etc. + static func verifyTagsFormat(_ tags: [String], siteTags: [String]) { + guard !siteTags.isEmpty else { return } + + // Detect format pattern from site tags + let hasHyphens = siteTags.contains { $0.contains("-") } + let hasUnderscores = siteTags.contains { $0.contains("_") } + let hasSpaces = siteTags.contains { $0.contains(" ") } + let hasUppercase = siteTags.contains { $0.rangeOfCharacter(from: .uppercaseLetters) != nil } + + for tag in tags { + // Skip format check for non-Latin scripts (Japanese, Chinese, etc.) + let isLatinScript = tag.rangeOfCharacter(from: CharacterSet.letters) != nil + guard isLatinScript else { continue } + + // Record warnings for format inconsistencies (not failures) + // LLM may reasonably vary formatting based on context + if hasHyphens && !tag.contains("-") && tag.contains(" ") { + Issue.record( + Comment(rawValue: "⚠️ Site tags use hyphens, but tag '\(tag)' uses spaces") + ) + } else if hasUnderscores && !tag.contains("_") && tag.contains(" ") { + Issue.record( + Comment(rawValue: "⚠️ Site tags use underscores, but tag '\(tag)' uses spaces") + ) + } + + // Check case consistency + let tagHasUppercase = tag.rangeOfCharacter(from: .uppercaseLetters) != nil + if !hasUppercase && tagHasUppercase { + Issue.record( + Comment(rawValue: "⚠️ Site tags are lowercase, but tag '\(tag)' has uppercase") + ) + } + } + } + + /// Print formatted tag results with context + static func printTagResults( + parameters: TagTestCaseParameters, + tags: [String], + duration: Duration + ) { + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + + printSectionHeader(parameters.testDescription) + + print("⏱️ Generated \(tags.count) tags in \(String(format: "%.3f", durationSeconds))s") + + if !parameters.siteTags.isEmpty { + print("🏷️ Site tags context: \(parameters.siteTags.count) tags") + } + print() + + for (i, tag) in tags.enumerated() { + let detectedLanguage = detectLanguage(tag) + let languageInfo = detectedLanguage.map { " [\($0.rawValue)]" } ?? "" + print(" \(i + 1). \(tag)\(languageInfo)") + } + + printSectionFooter() + } + + // MARK: - Summary Validation + + /// Verifies that a summary is in the expected language. + /// Uses NLLanguageRecognizer to detect the language of the summary. + static func verifySummaryLanguage(_ summary: String, expectedLanguage: NLLanguage) { + let detectedLanguage = detectLanguage(summary) + + #expect(detectedLanguage == expectedLanguage, + "Summary language mismatch: expected \(expectedLanguage.rawValue), got \(detectedLanguage?.rawValue ?? "unknown")") + } + + /// Prints formatted summary test results to console. + static func printSummaryResults( + parameters: SummaryTestCaseParameters, + summary: String, + duration: Duration + ) { + printSectionHeader("") + + // Test info + print("Test: \(parameters.testDescription)") + print("Language: \(parameters.data.languageCode.rawValue)") + + // Duration + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + print("Duration: \(String(format: "%.2f", durationSeconds))s") + + // Word count comparison + let summaryWordCount = summary.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + let originalWordCount = parameters.data.content.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + let compressionRatio = Double(summaryWordCount) / Double(originalWordCount) * 100.0 + print("Compression: \(originalWordCount) → \(summaryWordCount) words (\(String(format: "%.1f", compressionRatio))%)") + + print("") + + // Summary content + print("Summary:") + print(summary.wrapped(width: 80).split(separator: "\n").map { " \($0)" }.joined(separator: "\n")) + + printSectionFooter() + } +} + +// MARK: - String Extensions + +private extension String { + /// Wraps text to specified width while preserving words. + /// Accounts for emoji visual width in terminals. + func wrapped(width: Int) -> String { + var result = "" + var currentLine = "" + var currentWidth = 0 + + for word in self.split(separator: " ") { + let wordWidth = TestHelpers.visualLength(String(word)) + + 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/Modules/Tests/WordPressIntelligenceTests/ImageAltTextGeneratorTests.swift b/Modules/Tests/WordPressIntelligenceTests/ImageAltTextGeneratorTests.swift new file mode 100644 index 000000000000..58f64c4e2ec6 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/ImageAltTextGeneratorTests.swift @@ -0,0 +1,371 @@ +import Testing +import Foundation +import FoundationModels +import UIKit +@testable import WordPressIntelligence + +@Suite(.serialized) +struct ImageAltTextGeneratorTests { + + // MARK: - Basic Tests + + @available(iOS 26, *) + @Test("Generate alt text with full metadata") + func generateAltTextWithFullMetadata() async throws { + let metadata = MediaMetadata( + filename: "beach-sunset.jpg", + title: "Beautiful Beach Sunset", + caption: "A stunning view of the ocean at golden hour", + description: "Photograph taken at Bondi Beach during sunset", + altText: nil, + fileType: "JPEG", + dimensions: "1920x1080", + imageAnalysis: "Scene: beach, sunset, ocean; Colors: orange, purple, blue" + ) + + let generator = ImageAltTextGenerator() + let (altText, duration) = try await TestHelpers.measure { + try await generator.generate(metadata: metadata) + } + + // Validations + #expect(!altText.isEmpty, "Alt text should not be empty") + #expect(altText.count <= 125, "Alt text should be concise (max 125 characters)") + #expect(!altText.lowercased().contains("image of"), "Alt text should not contain 'image of'") + #expect(!altText.lowercased().contains("picture of"), "Alt text should not contain 'picture of'") + + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + print("✓ Generated alt text in \(String(format: "%.2f", durationSeconds))s: \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text with minimal metadata") + func generateAltTextWithMinimalMetadata() async throws { + let metadata = MediaMetadata( + filename: "photo.jpg", + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: "JPEG", + dimensions: nil, + imageAnalysis: nil + ) + + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(metadata: metadata) + + #expect(!altText.isEmpty, "Alt text should be generated even with minimal metadata") + print("✓ Minimal metadata alt text: \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text with image analysis only") + func generateAltTextWithImageAnalysisOnly() async throws { + let metadata = MediaMetadata( + filename: nil, + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: "Scene: mountain, landscape, snow; Objects: trees, rocks" + ) + + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(metadata: metadata) + + #expect(!altText.isEmpty, "Alt text should be generated from image analysis") + print("✓ Image analysis-only alt text: \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text with real image analysis from cat.jpg") + func generateAltTextWithRealImageAnalysis() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg"), + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData), + let cgImage = image.cgImage else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Perform real image analysis using Vision framework + let imageAnalysis = try await IntelligenceService.analyzeImage(cgImage) + #expect(!imageAnalysis.isEmpty, "Image analysis should return results") + print("✓ Real image analysis result: \"\(imageAnalysis)\"") + + // Generate alt text using only the image analysis + let metadata = MediaMetadata( + filename: nil, + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: imageAnalysis + ) + + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(metadata: metadata) + + #expect(!altText.isEmpty, "Alt text should be generated from real image analysis") + #expect(altText.count <= 125, "Alt text should be under 125 characters") + print("✓ Alt text from real image analysis: \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text using convenience API with CGImage") + func generateAltTextWithConvenienceAPICGImage() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData), + let cgImage = image.cgImage else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Use convenience API - Vision analysis is automatic + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(cgImage: cgImage) + + #expect(!altText.isEmpty, "Alt text should be generated") + #expect(altText.count <= 125, "Alt text should be under 125 characters") + print("✓ Alt text from convenience API (CGImage): \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text using convenience API with UIImage") + func generateAltTextWithConvenienceAPIUIImage() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData) else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Use convenience API with UIImage + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(image: image) + + #expect(!altText.isEmpty, "Alt text should be generated") + #expect(altText.count <= 125, "Alt text should be under 125 characters") + print("✓ Alt text from convenience API (UIImage): \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text using convenience API with Data") + func generateAltTextWithConvenienceAPIData() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL) else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Use convenience API with Data + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(imageData: imageData) + + #expect(!altText.isEmpty, "Alt text should be generated") + #expect(altText.count <= 125, "Alt text should be under 125 characters") + print("✓ Alt text from convenience API (Data): \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text using convenience API with additional metadata") + func generateAltTextWithConvenienceAPIAndMetadata() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData), + let cgImage = image.cgImage else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Use convenience API with additional metadata + let metadata = MediaMetadata( + filename: "cat.jpg", + title: "Cute Cat", + caption: nil, + description: "A photo of a cat", + altText: nil, + fileType: "JPEG", + dimensions: "1024x680", + imageAnalysis: nil // Will be populated automatically + ) + + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(cgImage: cgImage, metadata: metadata) + + #expect(!altText.isEmpty, "Alt text should be generated") + #expect(altText.count <= 125, "Alt text should be under 125 characters") + print("✓ Alt text from convenience API with metadata: \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Generate alt text prioritizes image analysis") + func generateAltTextPrioritizesImageAnalysis() async throws { + let metadata = MediaMetadata( + filename: "document.pdf", + title: "Document", + caption: nil, + description: nil, + altText: nil, + fileType: "PDF", + dimensions: nil, + imageAnalysis: "Scene: forest, trees, wildlife; Objects: deer, birds" + ) + + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(metadata: metadata) + + #expect(!altText.isEmpty, "Alt text should be generated") + // The alt text should reflect the image analysis (forest/trees/wildlife) rather than just "document" + let lowerAltText = altText.lowercased() + let hasImageContent = lowerAltText.contains("forest") || + lowerAltText.contains("tree") || + lowerAltText.contains("wildlife") || + lowerAltText.contains("deer") || + lowerAltText.contains("bird") + #expect(hasImageContent, "Alt text should prioritize image analysis content") + print("✓ Analysis-prioritized alt text: \"\(altText)\"") + } + + // MARK: - Edge Cases + + @available(iOS 26, *) + @Test("Insufficient metadata throws error") + func insufficientMetadataThrowsError() async throws { + let metadata = MediaMetadata( + filename: nil, + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: nil + ) + + let generator = ImageAltTextGenerator() + + do { + _ = try await generator.generate(metadata: metadata) + Issue.record("Expected error for insufficient metadata but generation succeeded") + } catch { + let errorDescription = (error as NSError).userInfo[NSLocalizedDescriptionKey] as? String + #expect(errorDescription?.contains("Insufficient metadata") ?? false, + "Error should indicate insufficient metadata") + print("✓ Correctly threw error for insufficient metadata") + } + } + + @available(iOS 26, *) + @Test("Alt text length is reasonable") + func altTextLengthIsReasonable() async throws { + let metadata = MediaMetadata( + filename: "team-meeting.jpg", + title: "Q4 Team Strategy Meeting", + caption: "Our team discussing the quarterly strategy and planning for next year's initiatives", + description: "A professional meeting room with team members gathered around a conference table", + altText: nil, + fileType: "JPEG", + dimensions: "2048x1536", + imageAnalysis: "Scene: office, meeting, indoor; Objects: people, table, laptops, documents" + ) + + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(metadata: metadata) + + let wordCount = altText.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + #expect(wordCount >= 3, "Alt text should have at least a few words") + #expect(wordCount <= 25, "Alt text should be concise (typically under 25 words)") + #expect(altText.count <= 125, "Alt text should be under 125 characters") + print("✓ Alt text length is appropriate (\(wordCount) words, \(altText.count) chars): \"\(altText)\"") + } + + @available(iOS 26, *) + @Test("Performance benchmark") + func performanceBenchmark() async throws { + let metadata = MediaMetadata( + filename: "landscape.jpg", + title: "Mountain Landscape", + caption: "Scenic mountain view", + description: "A beautiful mountain landscape with snow-capped peaks", + altText: nil, + fileType: "JPEG", + dimensions: "4000x3000", + imageAnalysis: "Scene: mountain, landscape, outdoor; Colors: white, blue, green" + ) + + let generator = ImageAltTextGenerator() + let (altText, duration) = try await TestHelpers.measure { + try await generator.generate(metadata: metadata) + } + + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + #expect(duration <= .seconds(10), "Generation should complete within reasonable time") + #expect(!altText.isEmpty, "Should generate alt text") + + print("✓ Performance: Generated alt text in \(String(format: "%.3f", durationSeconds))s") + } + + // MARK: - Prompt Building Tests + + @available(iOS 26, *) + @Test("Prompt includes all provided metadata") + func promptIncludesAllMetadata() async throws { + let metadata = MediaMetadata( + filename: "test.jpg", + title: "Test Title", + caption: "Test Caption", + description: "Test Description", + altText: nil, + fileType: "JPEG", + dimensions: "1024x768", + imageAnalysis: "Test Analysis" + ) + + let generator = ImageAltTextGenerator() + let prompt = generator.makePrompt(metadata: metadata) + + #expect(prompt.contains("test.jpg"), "Prompt should include filename") + #expect(prompt.contains("Test Title"), "Prompt should include title") + #expect(prompt.contains("Test Caption"), "Prompt should include caption") + #expect(prompt.contains("Test Description"), "Prompt should include description") + #expect(prompt.contains("JPEG"), "Prompt should include file type") + #expect(prompt.contains("1024x768"), "Prompt should include dimensions") + #expect(prompt.contains("Test Analysis"), "Prompt should include image analysis") + print("✓ Prompt correctly includes all metadata fields") + } + + @available(iOS 26, *) + @Test("Prompt excludes nil fields") + func promptExcludesNilFields() async throws { + let metadata = MediaMetadata( + filename: "test.jpg", + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: nil + ) + + let generator = ImageAltTextGenerator() + let prompt = generator.makePrompt(metadata: metadata) + + #expect(!prompt.contains("TITLE:"), "Prompt should not include TITLE when nil") + #expect(!prompt.contains("CAPTION:"), "Prompt should not include CAPTION when nil") + #expect(!prompt.contains("DESCRIPTION:"), "Prompt should not include DESCRIPTION when nil") + print("✓ Prompt correctly excludes nil fields") + } +} diff --git a/Modules/Tests/WordPressIntelligenceTests/ImageCaptionGeneratorTests.swift b/Modules/Tests/WordPressIntelligenceTests/ImageCaptionGeneratorTests.swift new file mode 100644 index 000000000000..ed45703eca38 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/ImageCaptionGeneratorTests.swift @@ -0,0 +1,402 @@ +import Testing +import Foundation +import FoundationModels +import UIKit +@testable import WordPressIntelligence + +@Suite(.serialized) +struct ImageCaptionGeneratorTests { + + // MARK: - Basic Tests + + @available(iOS 26, *) + @Test("Generate caption with full metadata") + func generateCaptionWithFullMetadata() async throws { + let metadata = MediaMetadata( + filename: "beach-sunset.jpg", + title: "Beautiful Beach Sunset", + caption: nil, + description: "Photograph taken at Bondi Beach during sunset", + altText: "Golden sunset over ocean waves at Bondi Beach", + fileType: "JPEG", + dimensions: "1920x1080", + imageAnalysis: "Scene: beach, sunset, ocean; Colors: orange, purple, blue" + ) + + let generator = ImageCaptionGenerator() + let (caption, duration) = try await TestHelpers.measure { + try await generator.generate(metadata: metadata) + } + + // Validations + #expect(!caption.isEmpty, "Caption should not be empty") + + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + print("✓ Generated caption in \(String(format: "%.2f", durationSeconds))s: \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption with minimal metadata") + func generateCaptionWithMinimalMetadata() async throws { + let metadata = MediaMetadata( + filename: "photo.jpg", + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: "JPEG", + dimensions: nil, + imageAnalysis: nil + ) + + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(metadata: metadata) + + #expect(!caption.isEmpty, "Caption should be generated even with minimal metadata") + print("✓ Minimal metadata caption: \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption with image analysis only") + func generateCaptionWithImageAnalysisOnly() async throws { + let metadata = MediaMetadata( + filename: nil, + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: "Scene: mountain, landscape, snow; Objects: trees, rocks; Colors: white, blue, green" + ) + + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(metadata: metadata) + + #expect(!caption.isEmpty, "Caption should be generated from image analysis") + print("✓ Image analysis-only caption: \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption with real image analysis from cat.jpg") + func generateCaptionWithRealImageAnalysis() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData), + let cgImage = image.cgImage else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Perform real image analysis using Vision framework + let imageAnalysis = try await IntelligenceService.analyzeImage(cgImage) + #expect(!imageAnalysis.isEmpty, "Image analysis should return results") + print("✓ Real image analysis result: \"\(imageAnalysis)\"") + + // Generate caption using only the image analysis + let metadata = MediaMetadata( + filename: nil, + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: imageAnalysis + ) + + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(metadata: metadata) + + #expect(!caption.isEmpty, "Caption should be generated from real image analysis") + print("✓ Caption from real image analysis: \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption using convenience API with CGImage") + func generateCaptionWithConvenienceAPICGImage() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData), + let cgImage = image.cgImage else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Use convenience API - Vision analysis is automatic + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(cgImage: cgImage) + + #expect(!caption.isEmpty, "Caption should be generated") + print("✓ Caption from convenience API (CGImage): \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption using convenience API with UIImage") + func generateCaptionWithConvenienceAPIUIImage() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData) else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Use convenience API with UIImage + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(image: image) + + #expect(!caption.isEmpty, "Caption should be generated") + print("✓ Caption from convenience API (UIImage): \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption using convenience API with Data") + func generateCaptionWithConvenienceAPIData() async throws { + // Load cat.jpg from test resources + guard let imageURL = Bundle.module.url(forResource: "cat", withExtension: "jpg", subdirectory: "Resources"), + let imageData = try? Data(contentsOf: imageURL) else { + Issue.record("Failed to load cat.jpg from test resources") + return + } + + // Use convenience API with Data + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(imageData: imageData) + + #expect(!caption.isEmpty, "Caption should be generated") + print("✓ Caption from convenience API (Data): \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption prioritizes image analysis") + func generateCaptionPrioritizesImageAnalysis() async throws { + let metadata = MediaMetadata( + filename: "document.pdf", + title: "Document", + caption: nil, + description: nil, + altText: nil, + fileType: "PDF", + dimensions: nil, + imageAnalysis: "Scene: forest, trees, wildlife; Objects: deer, birds; Colors: green, brown" + ) + + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(metadata: metadata) + + #expect(!caption.isEmpty, "Caption should be generated") + // The caption should reflect the image analysis (forest/trees/wildlife) rather than just "document" + let lowerCaption = caption.lowercased() + let hasImageContent = lowerCaption.contains("forest") || + lowerCaption.contains("tree") || + lowerCaption.contains("wildlife") || + lowerCaption.contains("deer") || + lowerCaption.contains("bird") || + lowerCaption.contains("nature") + #expect(hasImageContent, "Caption should prioritize image analysis content") + print("✓ Analysis-prioritized caption: \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Generate caption with existing alt text") + func generateCaptionWithExistingAltText() async throws { + let metadata = MediaMetadata( + filename: "coffee-shop.jpg", + title: "Morning Coffee", + caption: nil, + description: nil, + altText: "Person enjoying coffee at outdoor cafe table", + fileType: "JPEG", + dimensions: "1600x900", + imageAnalysis: "Scene: cafe, outdoor, urban; Objects: coffee cup, person, table, chair" + ) + + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(metadata: metadata) + + #expect(!caption.isEmpty, "Caption should be generated") + // Caption can be more creative/engaging than the alt text + print("✓ Caption with alt text context: \"\(caption)\"") + } + + // MARK: - Edge Cases + + @available(iOS 26, *) + @Test("Insufficient metadata throws error") + func insufficientMetadataThrowsError() async throws { + let metadata = MediaMetadata( + filename: nil, + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: nil + ) + + let generator = ImageCaptionGenerator() + + do { + _ = try await generator.generate(metadata: metadata) + Issue.record("Expected error for insufficient metadata but generation succeeded") + } catch { + let errorDescription = (error as NSError).userInfo[NSLocalizedDescriptionKey] as? String + #expect(errorDescription?.contains("Insufficient metadata") ?? false, + "Error should indicate insufficient metadata") + print("✓ Correctly threw error for insufficient metadata") + } + } + + @available(iOS 26, *) + @Test("Caption length is reasonable") + func captionLengthIsReasonable() async throws { + let metadata = MediaMetadata( + filename: "team-meeting.jpg", + title: "Q4 Team Strategy Meeting", + caption: nil, + description: "A professional meeting room with team members gathered around a conference table", + altText: "Team members collaborating in conference room", + fileType: "JPEG", + dimensions: "2048x1536", + imageAnalysis: "Scene: office, meeting, indoor; Objects: people, table, laptops, documents" + ) + + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(metadata: metadata) + + let wordCount = caption.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + // Captions can be slightly longer than alt text (1-2 sentences) + #expect(wordCount >= 3, "Caption should have at least a few words") + #expect(wordCount <= 50, "Caption should be concise (typically 1-2 sentences)") + print("✓ Caption length is appropriate (\(wordCount) words): \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Caption is more creative than alt text would be") + func captionIsMoreCreative() async throws { + let metadata = MediaMetadata( + filename: "sunrise.jpg", + title: "Morning Sunrise", + caption: nil, + description: "Early morning landscape", + altText: "Sunrise over mountains with clouds", + fileType: "JPEG", + dimensions: "3000x2000", + imageAnalysis: "Scene: sunrise, mountains, clouds; Colors: orange, pink, purple, blue" + ) + + let generator = ImageCaptionGenerator() + let caption = try await generator.generate(metadata: metadata) + + #expect(!caption.isEmpty, "Caption should be generated") + // Caption should be more than just a description - it can be creative/engaging + // We just verify it was generated successfully; manual inspection would confirm creativity + print("✓ Creative caption: \"\(caption)\"") + } + + @available(iOS 26, *) + @Test("Performance benchmark") + func performanceBenchmark() async throws { + let metadata = MediaMetadata( + filename: "landscape.jpg", + title: "Mountain Landscape", + caption: nil, + description: "A beautiful mountain landscape with snow-capped peaks", + altText: "Snow-capped mountains under blue sky", + fileType: "JPEG", + dimensions: "4000x3000", + imageAnalysis: "Scene: mountain, landscape, outdoor; Colors: white, blue, green" + ) + + let generator = ImageCaptionGenerator() + let (caption, duration) = try await TestHelpers.measure { + try await generator.generate(metadata: metadata) + } + + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + #expect(duration <= .seconds(10), "Generation should complete within reasonable time") + #expect(!caption.isEmpty, "Should generate caption") + + print("✓ Performance: Generated caption in \(String(format: "%.3f", durationSeconds))s") + } + + // MARK: - Prompt Building Tests + + @available(iOS 26, *) + @Test("Prompt includes all provided metadata") + func promptIncludesAllMetadata() async throws { + let metadata = MediaMetadata( + filename: "test.jpg", + title: "Test Title", + caption: nil, + description: "Test Description", + altText: "Test Alt Text", + fileType: "JPEG", + dimensions: "1024x768", + imageAnalysis: "Test Analysis" + ) + + let generator = ImageCaptionGenerator() + let prompt = generator.makePrompt(metadata: metadata) + + #expect(prompt.contains("test.jpg"), "Prompt should include filename") + #expect(prompt.contains("Test Title"), "Prompt should include title") + #expect(prompt.contains("Test Description"), "Prompt should include description") + #expect(prompt.contains("Test Alt Text"), "Prompt should include alt text") + #expect(prompt.contains("JPEG"), "Prompt should include file type") + #expect(prompt.contains("1024x768"), "Prompt should include dimensions") + #expect(prompt.contains("Test Analysis"), "Prompt should include image analysis") + print("✓ Prompt correctly includes all metadata fields") + } + + @available(iOS 26, *) + @Test("Prompt excludes nil fields") + func promptExcludesNilFields() async throws { + let metadata = MediaMetadata( + filename: "test.jpg", + title: nil, + caption: nil, + description: nil, + altText: nil, + fileType: nil, + dimensions: nil, + imageAnalysis: nil + ) + + let generator = ImageCaptionGenerator() + let prompt = generator.makePrompt(metadata: metadata) + + #expect(!prompt.contains("TITLE:"), "Prompt should not include TITLE when nil") + #expect(!prompt.contains("CAPTION:"), "Prompt should not include CAPTION when nil") + #expect(!prompt.contains("DESCRIPTION:"), "Prompt should not include DESCRIPTION when nil") + #expect(!prompt.contains("ALT_TEXT:"), "Prompt should not include ALT_TEXT when nil") + print("✓ Prompt correctly excludes nil fields") + } + + @available(iOS 26, *) + @Test("Prompt includes alt text but not caption") + func promptIncludesAltTextNotCaption() async throws { + let metadata = MediaMetadata( + filename: "test.jpg", + title: "Test", + caption: "This should not appear", + description: nil, + altText: "This should appear", + fileType: nil, + dimensions: nil, + imageAnalysis: nil + ) + + let generator = ImageCaptionGenerator() + let prompt = generator.makePrompt(metadata: metadata) + + // Caption generator should use alt text as input but not the existing caption + #expect(prompt.contains("This should appear"), "Prompt should include alt text") + #expect(!prompt.contains("CAPTION:"), "Prompt should not include existing caption") + print("✓ Prompt correctly uses alt text but excludes caption field") + } +} diff --git a/Modules/Tests/WordPressIntelligenceTests/PostExcerptGeneratorTests.swift b/Modules/Tests/WordPressIntelligenceTests/PostExcerptGeneratorTests.swift new file mode 100644 index 000000000000..36d0f7c74ea5 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/PostExcerptGeneratorTests.swift @@ -0,0 +1,309 @@ +import Testing +import Foundation +import FoundationModels +import NaturalLanguage +@testable import WordPressIntelligence + +@Suite(.serialized) +struct PostExcerptGeneratorTests { + // MARK: - Standard Test Cases + + @available(iOS 26, *) + @Test(arguments: ExcerptTestCaseParameters.englishCases) + func excerptGenerationEnglish(parameters: ExcerptTestCaseParameters) async throws { + _ = try await runExcerptTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test(arguments: ExcerptTestCaseParameters.nonEnglishCases) + func excerptGenerationNonEnglish(parameters: ExcerptTestCaseParameters) async throws { + _ = try await runExcerptTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test(arguments: ExcerptTestCaseParameters.unsupportedLanguageCases) + func unsupportedLanguages(parameters: ExcerptTestCaseParameters) async throws { + let generator = PostExcerptGenerator(length: parameters.length, style: parameters.style) + + do { + _ = try await generator.generate(for: parameters.data.content) + Issue.record("Expected unsupportedLanguageOrLocale error but no error was thrown") + } catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale { + return + } catch { + Issue.record("Expected unsupportedLanguageOrLocale error but got: \(error)") + } + } + + @available(iOS 26, *) + @Test("HTML content") + func htmlContent() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.englishPostWithHTML, + length: .medium, + style: .engaging + ) + _ = try await runExcerptTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Very short content") + func veryShortContent() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.veryShortEnglishContent, + length: .short, + style: .engaging + ) + _ = try await runExcerptTest(parameters: parameters) + } + + // MARK: - Error Handling Tests + + @available(iOS 26, *) + @Test("Empty content") + func emptyContent() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.emptyContent, + length: .short, + style: .engaging + ) + let generator = PostExcerptGenerator(length: parameters.length, style: parameters.style) + + // Empty content should either throw an error or return empty excerpts + do { + let excerpts = try await generator.generate(for: parameters.data.content) + // If it doesn't throw, verify it returns empty or sensible default + #expect(excerpts.isEmpty || excerpts.allSatisfy { $0.isEmpty }) + } catch { + // Expected to throw for empty content - this is acceptable behavior + return + } + } + + @available(iOS 26, *) + @Test("Very long content (>10K words)") + func veryLongContent() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.veryLongContent, + length: .medium, + style: .professional + ) + + // Should handle gracefully - either generate excerpts or throw appropriate error + // Allow longer processing time for very long content + do { + let (excerpts, _) = try await runExcerptTest( + parameters: parameters, + maxDuration: .seconds(30) + ) + + // If successful, verify excerpts are reasonable despite long input + #expect(!excerpts.isEmpty) + + // Word count should still be within bounds + for excerpt in excerpts { + let wordCount = TestHelpers.countWords(excerpt, language: .english) + #expect(parameters.length.wordRange.contains(wordCount), + "Word count \(wordCount) out of range for long content") + } + } catch { + // May throw due to content length limits - this is acceptable + return + } + } + + @available(iOS 26, *) + @Test("Performance benchmark") + func performanceBenchmark() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.englishTechPost, + length: .medium, + style: .engaging + ) + + // Standard content should complete within 5 seconds + let (excerpts, duration) = try await runExcerptTest( + parameters: parameters, + maxDuration: .seconds(5) + ) + + // Verify generation was successful + #expect(!excerpts.isEmpty) + + // Log performance for tracking + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + print("Performance: Generated \(excerpts.count) excerpts in \(String(format: "%.2f", durationSeconds))s") + } + + @available(iOS 26, *) + @Test("Malformed HTML") + func malformedHTML() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.malformedHTML, + length: .short, + style: .conversational + ) + + // Should handle malformed HTML gracefully (extract text or clean it up) + let (excerpts, _) = try await runExcerptTest(parameters: parameters) + + // Verify excerpts don't contain HTML tags + for excerpt in excerpts { + #expect(!excerpt.contains("<") && !excerpt.contains(">"), + "Excerpt should not contain HTML tags: \(excerpt)") + } + + // Verify excerpts are not empty (HTML was successfully processed) + #expect(!excerpts.isEmpty) + #expect(excerpts.allSatisfy { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) + } + + @available(iOS 26, *) + @Test("Content with emojis and special Unicode characters") + func emojiAndSpecialCharacters() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.emojiAndSpecialCharacters, + length: .medium, + style: .engaging + ) + + let (excerpts, _) = try await runExcerptTest(parameters: parameters) + + // Verify excerpts are generated successfully + #expect(!excerpts.isEmpty) + + // Verify excerpts handle Unicode correctly (no corruption or truncation) + for excerpt in excerpts { + // Should not be empty after Unicode processing + #expect(!excerpt.trimmingCharacters(in: .whitespaces).isEmpty) + + // Check that excerpts preserve some Unicode content or handle it gracefully + // (may or may not include emojis depending on generation logic) + let hasContent = excerpt.count > 10 + #expect(hasContent, "Excerpt should have meaningful content despite Unicode") + } + } + + @available(iOS 26, *) + @Test("Mixed language content") + func mixedLanguageContent() async throws { + let parameters = ExcerptTestCaseParameters( + data: TestData.mixedLanguagePost, + length: .medium, + style: .professional + ) + + // Skip language check since content is intentionally mixed + let (excerpts, _) = try await runExcerptTest( + parameters: parameters, + skip: .skipLanguageCheck + ) + + // Should generate excerpts for mixed language content + #expect(!excerpts.isEmpty) + + // Verify excerpts have reasonable word counts + for excerpt in excerpts { + let wordCount = TestHelpers.countWords(excerpt, language: .english) + #expect(parameters.length.wordRange.contains(wordCount), + "Mixed language excerpt word count \(wordCount) out of range") + } + } + + // MARK: - Helper Types + + /// Validation options for excerpt generation tests + struct ValidationOptions: OptionSet { + let rawValue: Int + + static let skipLanguageCheck = ValidationOptions(rawValue: 1 << 0) + static let skipWordCountCheck = ValidationOptions(rawValue: 1 << 1) + static let skipDiversityCheck = ValidationOptions(rawValue: 1 << 2) + + static let all: ValidationOptions = [] + static let skipAll: ValidationOptions = [.skipLanguageCheck, .skipWordCountCheck, .skipDiversityCheck] + } + + // MARK: - Helper Methods + + /// Reusable test helper that runs excerpt generation and performs standard validations + @available(iOS 26, *) + private func runExcerptTest( + parameters: ExcerptTestCaseParameters, + skip: ValidationOptions = [], + maxDuration: Duration? = .seconds(10) + ) async throws -> ([String], Duration) { + let generator = PostExcerptGenerator(length: parameters.length, style: parameters.style) + + let (excerpts, duration) = try await TestHelpers.measure { + try await generator.generate(for: parameters.data.content) + } + + // Performance benchmark + if let maxDuration { + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + let maxSeconds = Double(maxDuration.components.seconds) + Double(maxDuration.components.attoseconds) / 1e18 + #expect( + duration <= maxDuration, + "Generation took too long: \(String(format: "%.2f", durationSeconds))s (max: \(String(format: "%.2f", maxSeconds))s)" + ) + } + + if !skip.contains(.skipLanguageCheck) { + TestHelpers.verifyExcerptsLanguage(excerpts, expectedLanguage: parameters.data.languageCode) + } + + if !skip.contains(.skipWordCountCheck) { + TestHelpers.verifyExcerptsWordCount( + excerpts, + wordRange: parameters.length.wordRange, + language: parameters.data.languageCode + ) + } + + if !skip.contains(.skipDiversityCheck) { + TestHelpers.verifyExcerptsDiversity(excerpts) + } + + try? ExcerptTestOutput( + parameters: parameters, + excerpts: excerpts, + duration: duration + ).recordAndPrint(parameters: parameters, duration: duration) + + return (excerpts, duration) + } +} + +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 englishCases: [ExcerptTestCaseParameters] = [ + ExcerptTestCaseParameters(data: Data.englishTechPost, length: .short, style: .witty), + ExcerptTestCaseParameters(data: Data.englishAcademicPost, length: .medium, style: .formal), + ExcerptTestCaseParameters(data: Data.englishStoryPost, length: .long, style: .engaging), + ] + + static let nonEnglishCases: [ExcerptTestCaseParameters] = [ + 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), + ExcerptTestCaseParameters(data: Data.mandarinPost, length: .medium, style: .engaging), + ] + + static let unsupportedLanguageCases: [ExcerptTestCaseParameters] = [ + ExcerptTestCaseParameters(data: Data.hindiPost, length: .short, style: .conversational), + ExcerptTestCaseParameters(data: Data.russianPost, length: .medium, style: .formal), + ] + + static let allCases: [ExcerptTestCaseParameters] = englishCases + nonEnglishCases +} diff --git a/Modules/Tests/WordPressIntelligenceTests/PostSummaryGeneratorTests.swift b/Modules/Tests/WordPressIntelligenceTests/PostSummaryGeneratorTests.swift new file mode 100644 index 000000000000..e06b51bc1162 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/PostSummaryGeneratorTests.swift @@ -0,0 +1,228 @@ +import Testing +import Foundation +import FoundationModels +import NaturalLanguage +@testable import WordPressIntelligence + +@Suite(.serialized) +struct PostSummaryGeneratorTests { + // MARK: - Standard Test Cases + + @available(iOS 26, *) + @Test(arguments: SummaryTestCaseParameters.allCases) + func postSummary(parameters: SummaryTestCaseParameters) async throws { + _ = try await runSummaryTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test(arguments: SummaryTestCaseParameters.unsupportedLanguageCases) + func unsupportedLanguages(parameters: SummaryTestCaseParameters) async throws { + let generator = PostSummaryGenerator() + + do { + _ = try await generator.generate(content: parameters.data.content) + Issue.record("Expected unsupportedLanguageOrLocale error but no error was thrown") + } catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale { + return + } catch { + Issue.record("Expected unsupportedLanguageOrLocale error but got: \(error)") + } + } + + // MARK: - Edge Case Tests + + @available(iOS 26, *) + @Test("HTML content") + func htmlContent() async throws { + let parameters = SummaryTestCaseParameters( + data: TestData.englishPostWithHTML + ) + _ = try await runSummaryTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Malformed HTML") + func malformedHTML() async throws { + let parameters = SummaryTestCaseParameters( + data: TestData.malformedHTML + ) + _ = try await runSummaryTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Very short content") + func veryShortContent() async throws { + let parameters = SummaryTestCaseParameters( + data: TestData.veryShortEnglishContent + ) + _ = try await runSummaryTest(parameters: parameters, skip: [.skipLengthCheck]) + } + + @available(iOS 26, *) + @Test("Very long content (>10K words)") + func veryLongContent() async throws { + let parameters = SummaryTestCaseParameters( + data: TestData.veryLongContent + ) + + do { + let (summary, _) = try await runSummaryTest( + parameters: parameters, + maxDuration: .seconds(30) + ) + #expect(!summary.isEmpty, "Should generate summary even for very long content") + } catch { + // May throw due to content length limits - this is acceptable + return + } + } + + @available(iOS 26, *) + @Test("Emoji and special Unicode characters") + func emojiAndSpecialCharacters() async throws { + let parameters = SummaryTestCaseParameters( + data: TestData.emojiAndSpecialCharacters + ) + _ = try await runSummaryTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Mixed language content") + func mixedLanguageContent() async throws { + let parameters = SummaryTestCaseParameters( + data: TestData.mixedLanguagePost + ) + + // Skip language check since content is intentionally mixed + _ = try await runSummaryTest( + parameters: parameters, + skip: .skipLanguageCheck + ) + } + + @available(iOS 26, *) + @Test("Performance benchmark") + func performanceBenchmark() async throws { + let parameters = SummaryTestCaseParameters( + data: TestData.englishTechPost + ) + + let (summary, duration) = try await runSummaryTest( + parameters: parameters, + maxDuration: .seconds(5) + ) + + #expect(!summary.isEmpty, "Should generate summary") + + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + print("Performance: Generated summary in \(String(format: "%.2f", durationSeconds))s") + } + + // MARK: - Helper Types + + /// Validation options for summary tests + struct ValidationOptions: OptionSet { + let rawValue: Int + + static let skipLanguageCheck = ValidationOptions(rawValue: 1 << 0) + static let skipLengthCheck = ValidationOptions(rawValue: 1 << 1) + static let skipContentCheck = ValidationOptions(rawValue: 1 << 2) + + static let all: ValidationOptions = [] + static let skipAll: ValidationOptions = [.skipLanguageCheck, .skipLengthCheck, .skipContentCheck] + } + + // MARK: - Helper Methods + + /// Reusable test helper that runs summary generation and performs standard validations + @available(iOS 26, *) + private func runSummaryTest( + parameters: SummaryTestCaseParameters, + skip: ValidationOptions = [], + maxDuration: Duration? = .seconds(10) + ) async throws -> (String, Duration) { + let generator = PostSummaryGenerator() + + let (summary, duration) = try await TestHelpers.measure { + try await generator.generate(content: parameters.data.content) + } + + // Performance validation + if let maxDuration { + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + let maxSeconds = Double(maxDuration.components.seconds) + Double(maxDuration.components.attoseconds) / 1e18 + #expect( + duration <= maxDuration, + "Generation took too long: \(String(format: "%.2f", durationSeconds))s (max: \(String(format: "%.2f", maxSeconds))s)" + ) + } + + // Validation: Non-empty + #expect(!summary.isEmpty, "Summary should not be empty") + + // Validation: Language match + if !skip.contains(.skipLanguageCheck) { + TestHelpers.verifySummaryLanguage(summary, expectedLanguage: parameters.data.languageCode) + } + + // Validation: Reasonable length (should be shorter than original) + if !skip.contains(.skipLengthCheck) { + let summaryWordCount = summary.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + let originalWordCount = parameters.data.content.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + #expect(summaryWordCount < originalWordCount, + "Summary (\(summaryWordCount) words) should be shorter than original (\(originalWordCount) words)") + } + + // Validation: Content relevance (should not be a generic response) + if !skip.contains(.skipContentCheck) { + let genericPhrases = ["this post", "this article", "the author"] + let hasSpecificContent = !genericPhrases.allSatisfy { summary.lowercased().contains($0.lowercased()) } + #expect(hasSpecificContent, "Summary should contain specific content, not just generic phrases") + } + + // Record structured output for evaluation + try? SummaryTestOutput( + parameters: parameters, + summary: summary, + duration: duration + ).recordAndPrint(parameters: parameters, duration: duration) + + return (summary, duration) + } +} + +struct SummaryTestCaseParameters: CustomTestStringConvertible { + let data: TestContent + + var testDescription: String { + data.title + } + + typealias Data = TestData + + static let allCases: [SummaryTestCaseParameters] = [ + // English + SummaryTestCaseParameters(data: Data.englishTechPost), + SummaryTestCaseParameters(data: Data.englishPost), + + // Spanish + SummaryTestCaseParameters(data: Data.spanishPost), + + // French + SummaryTestCaseParameters(data: Data.frenchPost), + + // Japanese + SummaryTestCaseParameters(data: Data.japanesePost), + + // German + SummaryTestCaseParameters(data: Data.germanTechPost), + + // Mandarin + SummaryTestCaseParameters(data: Data.mandarinPost), + ] + + static let unsupportedLanguageCases: [SummaryTestCaseParameters] = [ + SummaryTestCaseParameters(data: Data.hindiPost), + SummaryTestCaseParameters(data: Data.russianPost), + ] +} diff --git a/Modules/Tests/WordPressIntelligenceTests/Resources/cat.jpg b/Modules/Tests/WordPressIntelligenceTests/Resources/cat.jpg new file mode 100644 index 000000000000..0cb31b702fbd Binary files /dev/null and b/Modules/Tests/WordPressIntelligenceTests/Resources/cat.jpg differ diff --git a/Modules/Tests/WordPressIntelligenceTests/TagSuggestionGeneratorTests.swift b/Modules/Tests/WordPressIntelligenceTests/TagSuggestionGeneratorTests.swift new file mode 100644 index 000000000000..878502f565a9 --- /dev/null +++ b/Modules/Tests/WordPressIntelligenceTests/TagSuggestionGeneratorTests.swift @@ -0,0 +1,280 @@ +import Testing +import Foundation +import FoundationModels +import NaturalLanguage +@testable import WordPressIntelligence + +@Suite(.serialized) +struct TagSuggestionGeneratorTests { + // MARK: - Standard Test Cases + + @available(iOS 26, *) + @Test(arguments: TagTestCaseParameters.englishCases) + func tagSuggestionEnglish(parameters: TagTestCaseParameters) async throws { + _ = try await runTagTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test(arguments: TagTestCaseParameters.nonEnglishCases) + func tagSuggestionNonEnglish(parameters: TagTestCaseParameters) async throws { + _ = try await runTagTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test(arguments: TagTestCaseParameters.unsupportedLanguageCases) + func unsupportedLanguages(parameters: TagTestCaseParameters) async throws { + let generator = TagSuggestionGenerator() + + do { + _ = try await generator.generate( + post: parameters.data.content, + siteTags: parameters.siteTags, + postTags: parameters.postTags + ) + Issue.record("Expected unsupportedLanguageOrLocale error but no error was thrown") + } catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale { + return + } catch { + Issue.record("Expected unsupportedLanguageOrLocale error but got: \(error)") + } + } + + // MARK: - Edge Case Tests + + @available(iOS 26, *) + @Test("Exclude existing post tags") + func excludeExistingTags() async throws { + let parameters = TagTestCaseParameters( + data: TestData.englishTechPost, + siteTags: TestData.englishSiteTags, + postTags: ["programming", "technology"] + ) + let (tags, _) = try await runTagTest(parameters: parameters) + + #expect(!tags.contains { parameters.postTags.contains($0) }, + "Tags should not include existing post tags: \(parameters.postTags)") + } + + @available(iOS 26, *) + @Test("Empty site tags") + func emptySiteTags() async throws { + let parameters = TagTestCaseParameters( + data: TestData.englishPost, + siteTags: [], + postTags: [] + ) + _ = try await runTagTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Very short content") + func veryShortContent() async throws { + let parameters = TagTestCaseParameters( + data: TestData.veryShortEnglishContent, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + _ = try await runTagTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Very long content (>10K words)") + func veryLongContent() async throws { + let parameters = TagTestCaseParameters( + data: TestData.veryLongContent, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + + do { + let (tags, _) = try await runTagTest( + parameters: parameters, + maxDuration: .seconds(30) + ) + #expect(!tags.isEmpty, "Should generate tags even for very long content") + } catch { + // May throw due to content length limits - this is acceptable + return + } + } + + @available(iOS 26, *) + @Test("HTML content") + func htmlContent() async throws { + let parameters = TagTestCaseParameters( + data: TestData.englishPostWithHTML, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + _ = try await runTagTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Malformed HTML") + func malformedHTML() async throws { + let parameters = TagTestCaseParameters( + data: TestData.malformedHTML, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + _ = try await runTagTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Emoji and special Unicode characters") + func emojiAndSpecialCharacters() async throws { + let parameters = TagTestCaseParameters( + data: TestData.emojiAndSpecialCharacters, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + _ = try await runTagTest(parameters: parameters) + } + + @available(iOS 26, *) + @Test("Mixed language content") + func mixedLanguageContent() async throws { + let parameters = TagTestCaseParameters( + data: TestData.mixedLanguagePost, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + + // Skip language check since content is intentionally mixed + _ = try await runTagTest( + parameters: parameters, + skip: .skipLanguageCheck + ) + } + + @available(iOS 26, *) + @Test("Performance benchmark") + func performanceBenchmark() async throws { + let parameters = TagTestCaseParameters( + data: TestData.englishTechPost, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + + let (tags, duration) = try await runTagTest( + parameters: parameters, + maxDuration: .seconds(5) + ) + + #expect(!tags.isEmpty, "Should generate tags") + + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + print("Performance: Generated \(tags.count) tags in \(String(format: "%.2f", durationSeconds))s") + } + + // MARK: - Helper Types + + /// Validation options for tag suggestion tests + struct ValidationOptions: OptionSet { + let rawValue: Int + + static let skipLanguageCheck = ValidationOptions(rawValue: 1 << 0) + static let skipFormatCheck = ValidationOptions(rawValue: 1 << 1) + static let skipCountCheck = ValidationOptions(rawValue: 1 << 2) + + static let all: ValidationOptions = [] + static let skipAll: ValidationOptions = [.skipLanguageCheck, .skipFormatCheck, .skipCountCheck] + } + + // MARK: - Helper Methods + + /// Reusable test helper that runs tag generation and performs standard validations + @available(iOS 26, *) + private func runTagTest( + parameters: TagTestCaseParameters, + skip: ValidationOptions = [], + maxDuration: Duration? = .seconds(10) + ) async throws -> ([String], Duration) { + let generator = TagSuggestionGenerator() + + let (tags, duration) = try await TestHelpers.measure { + try await generator.generate( + post: parameters.data.content, + siteTags: parameters.siteTags, + postTags: parameters.postTags + ) + } + + // Performance validation + if let maxDuration { + let durationSeconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + let maxSeconds = Double(maxDuration.components.seconds) + Double(maxDuration.components.attoseconds) / 1e18 + #expect( + duration <= maxDuration, + "Generation took too long: \(String(format: "%.2f", durationSeconds))s (max: \(String(format: "%.2f", maxSeconds))s)" + ) + } + + // Validation: Language match + if !skip.contains(.skipLanguageCheck) { + TestHelpers.verifyTagsLanguage(tags, expectedLanguage: parameters.data.languageCode) + } + + // Validation: Format consistency + if !skip.contains(.skipFormatCheck) && !parameters.siteTags.isEmpty { + TestHelpers.verifyTagsFormat(tags, siteTags: parameters.siteTags) + } + + // Validation: Count (5-10 tags as per @Guide) + if !skip.contains(.skipCountCheck) { + #expect(tags.count >= 5 && tags.count <= 10, + "Expected 5-10 tags, got \(tags.count)") + } + + // Validation: Uniqueness + let uniqueTags = Set(tags) + #expect(uniqueTags.count == tags.count, + "Tags contain duplicates: \(tags)") + + // Validation: No existing post tags + let existingPostTags = Set(parameters.postTags) + #expect(!tags.contains { existingPostTags.contains($0) }, + "Tags should not include existing post tags") + + // Record structured output for evaluation + try? TagTestOutput( + parameters: parameters, + tags: tags, + duration: duration + ).recordAndPrint(parameters: parameters, duration: duration) + + return (tags, duration) + } +} + +struct TagTestCaseParameters: CustomTestStringConvertible { + let data: TestContent + let siteTags: [String] + let postTags: [String] + + var testDescription: String { + "\(data.title) - \(siteTags.count) site tags" + } + + typealias Data = TestData + + static let englishCases: [TagTestCaseParameters] = [ + TagTestCaseParameters(data: Data.englishTechPost, siteTags: Data.englishSiteTags, postTags: []), + TagTestCaseParameters(data: Data.englishPost, siteTags: Data.englishSiteTags, postTags: []), + ] + + static let nonEnglishCases: [TagTestCaseParameters] = [ + TagTestCaseParameters(data: Data.spanishPost, siteTags: Data.spanishSiteTags, postTags: []), + TagTestCaseParameters(data: Data.frenchPost, siteTags: Data.frenchSiteTags, postTags: []), + TagTestCaseParameters(data: Data.japanesePost, siteTags: Data.japaneseSiteTags, postTags: []), + TagTestCaseParameters(data: Data.germanTechPost, siteTags: Data.germanSiteTags, postTags: []), + TagTestCaseParameters(data: Data.mandarinPost, siteTags: Data.mandarinSiteTags, postTags: []), + ] + + static let unsupportedLanguageCases: [TagTestCaseParameters] = [ + TagTestCaseParameters(data: Data.hindiPost, siteTags: [], postTags: []), + TagTestCaseParameters(data: Data.russianPost, siteTags: [], postTags: []), + ] + + static let allCases: [TagTestCaseParameters] = englishCases + nonEnglishCases +} diff --git a/Modules/Tests/WordPressSharedTests/GutenbergExcerptGeneratorTests.swift b/Modules/Tests/WordPressSharedTests/GutenbergExcerptGeneratorTests.swift index e6747a83e5fd..eff9361a7686 100644 --- a/Modules/Tests/WordPressSharedTests/GutenbergExcerptGeneratorTests.swift +++ b/Modules/Tests/WordPressSharedTests/GutenbergExcerptGeneratorTests.swift @@ -1,7 +1,7 @@ import Testing @testable import WordPressShared -struct GutenbergExcerptGeneratorTests { +struct GutenbergPostExcerptGeneratorTests { @Test func summaryForContent() { let content = "

Lorem ipsum dolor sit amet, [shortcode param=\"value\"]consectetur[/shortcode] adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

" diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift b/Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift deleted file mode 100644 index b9f4a8adccf6..000000000000 --- a/Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Testing -@testable import WordPressShared - -struct IntelligenceServiceTests { - @available(iOS 26, *) - @Test(.disabled("only for local testing")) - func suggestTags() async throws { - let tags = try await IntelligenceService() - .suggestTags( - post: IntelligenceUtilities.post, - siteTags: ["cooking", "healthy-foods"] - ) - print(tags) - } -} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 640aa517d748..43c4c49ecf15 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,8 +1,8 @@ 26.6 ----- +* [**] [Intelligence] Expand AI-based features to more locales [#25034] * [*] Fix previewing posts on WordPress.com atomic sites [#25045] - 26.5 ----- * [*] Add "Status" field to the "Post Settings" screen to make it easier to move posts from one state to another [#24939] diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index ab0264f11fbd..bca6d9447587 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -7,7 +7,19 @@ import JetpackStats struct ContentView: View { var body: some View { - Text("Hello, world!") + List { + Section("Intelligence") { + if #available(iOS 26, *) { + NavigationLink("Image Alt Generator") { + ImageAltGeneratorTestView() + } + } else { + Text("Image Alt Generator (iOS 26+ required)") + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Miniature") } } diff --git a/Sources/Miniature/ImageAltGeneratorTestView.swift b/Sources/Miniature/ImageAltGeneratorTestView.swift new file mode 100644 index 000000000000..f5162c4a1771 --- /dev/null +++ b/Sources/Miniature/ImageAltGeneratorTestView.swift @@ -0,0 +1,278 @@ +import SwiftUI +import PhotosUI +import WordPressIntelligence + +@available(iOS 26, *) +struct ImageAltGeneratorTestView: View { + @StateObject private var viewModel = ImageAltGeneratorTestViewModel() + + var body: some View { + ScrollView { + VStack(spacing: 20) { + // Image Picker + PhotosPicker(selection: $viewModel.selectedItem, + matching: .images) { + Label("Pick Image", systemImage: "photo.on.rectangle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + + // Selected Image + if let image = viewModel.selectedImage { + VStack(spacing: 12) { + Text("Selected Image") + .font(.headline) + + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxHeight: 300) + .cornerRadius(8) + .padding(.horizontal) + } + } + + // Analysis Status + if viewModel.isAnalyzing { + VStack(spacing: 8) { + ProgressView() + Text("Analyzing image...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + } + + // Analysis Result + if let analysisResult = viewModel.analysisResult { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Image Analysis") + .font(.headline) + Spacer() + if let elapsed = viewModel.analysisTimeElapsed { + Text(String(format: "%.0f ms", elapsed)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Text(analysisResult) + .font(.body) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + .padding(.horizontal) + } + + // Generation Status + if viewModel.isGenerating { + VStack(spacing: 8) { + ProgressView() + Text("Generating alt text...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + } + + // Generated Alt Text + if let altText = viewModel.generatedAltText { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Generated Alt Text") + .font(.headline) + Spacer() + if let elapsed = viewModel.generationTimeElapsed { + Text(String(format: "%.0f ms", elapsed)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Text(altText) + .font(.body) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGreen).opacity(0.1)) + .cornerRadius(8) + } + .padding(.horizontal) + } + + // Error Display + if let error = viewModel.errorMessage { + VStack(alignment: .leading, spacing: 8) { + Text("Error") + .font(.headline) + .foregroundStyle(.red) + + Text(error) + .font(.body) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemRed).opacity(0.1)) + .cornerRadius(8) + } + .padding(.horizontal) + } + } + .padding(.vertical) + } + .navigationTitle("Image Alt Generator") + .navigationBarTitleDisplayMode(.inline) + } +} + +@MainActor +@available(iOS 26, *) +final class ImageAltGeneratorTestViewModel: ObservableObject { + @Published var selectedItem: PhotosPickerItem? + @Published var selectedImage: UIImage? + @Published var analysisResult: String? + @Published var generatedAltText: String? + @Published var isAnalyzing = false + @Published var isGenerating = false + @Published var errorMessage: String? + @Published var analysisTimeElapsed: Double? + @Published var generationTimeElapsed: Double? + + private var currentCGImage: CGImage? + + init() { + // Observe selectedItem changes + Task { @MainActor in + for await item in $selectedItem.values { + await loadImage(from: item) + } + } + } + + private func loadImage(from item: PhotosPickerItem?) async { + guard let item else { + selectedImage = nil + currentCGImage = nil + analysisResult = nil + generatedAltText = nil + errorMessage = nil + analysisTimeElapsed = nil + generationTimeElapsed = nil + return + } + + do { + guard let data = try await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { + print("❌ Failed to load image data") + errorMessage = "Failed to load image data" + return + } + + selectedImage = image + currentCGImage = image.cgImage + analysisResult = nil + generatedAltText = nil + errorMessage = nil + analysisTimeElapsed = nil + generationTimeElapsed = nil + + print("✅ Image loaded successfully") + + // Automatically start analysis + await analyzeImage() + } catch { + print("❌ Error loading image: \(error)") + errorMessage = "Error loading image: \(error.localizedDescription)" + } + } + + func analyzeImage() async { + guard let cgImage = currentCGImage else { + errorMessage = "No image to analyze" + return + } + + isAnalyzing = true + errorMessage = nil + analysisTimeElapsed = nil + + let startTime = CFAbsoluteTimeGetCurrent() + + do { + print("\n🔍 Starting image analysis...") + let result = try await IntelligenceService.analyzeImage(cgImage) + + let elapsed = (CFAbsoluteTimeGetCurrent() - startTime) * 1000 + analysisTimeElapsed = elapsed + + print("✅ Analysis complete (\(String(format: "%.0f", elapsed)) ms):") + print(result) + print("") + + analysisResult = result + generatedAltText = nil + + // Automatically start generation + await generateAltText() + } catch { + print("❌ Analysis error: \(error)") + errorMessage = "Analysis failed: \(error.localizedDescription)" + } + + isAnalyzing = false + } + + func generateAltText() async { + guard currentCGImage != nil else { + errorMessage = "No image available" + return + } + + guard let analysis = analysisResult else { + errorMessage = "Please analyze the image first" + return + } + + isGenerating = true + errorMessage = nil + generationTimeElapsed = nil + + let startTime = CFAbsoluteTimeGetCurrent() + + do { + print("\n✨ Generating alt text...") + + let metadata = MediaMetadata( + filename: "test-image.jpg", + imageAnalysis: analysis + ) + + let generator = ImageAltTextGenerator() + let altText = try await generator.generate(metadata: metadata) + + let elapsed = (CFAbsoluteTimeGetCurrent() - startTime) * 1000 + generationTimeElapsed = elapsed + + print("✅ Alt text generated (\(String(format: "%.0f", elapsed)) ms):") + print(altText) + print("") + + generatedAltText = altText + } catch { + print("❌ Generation error: \(error)") + errorMessage = "Generation failed: \(error.localizedDescription)" + } + + isGenerating = false + } +} + +@available(iOS 26, *) +#Preview { + NavigationStack { + ImageAltGeneratorTestView() + } +} diff --git a/Sources/Reader/Reader.entitlements b/Sources/Reader/Reader.entitlements index c040628806ec..44eb29cea18e 100644 --- a/Sources/Reader/Reader.entitlements +++ b/Sources/Reader/Reader.entitlements @@ -2,20 +2,13 @@ - com.apple.developer.associated-domains - - webcredentials:wordpress.com - webcredentials:*.wordpress.com - aps-environment development + com.apple.developer.associated-domains + com.apple.security.application-groups - - group.org.wordpress - + keychain-access-groups - - 3TMU3BH3NK.org.wordpress - + 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/Media/MediaItemViewController.swift b/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift index bf8166cea368..c73024a1a224 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift @@ -22,6 +22,24 @@ final class MediaItemViewController: UITableViewController { private let headerView = MediaItemHeaderView() private lazy var headerMaxHeightConstraint = headerView.heightAnchor.constraint(lessThanOrEqualToConstant: 320) + private var _textGenerationController: AnyObject? + + @available(iOS 26, *) + private var textGenerationController: MediaTextGenerationController { + if _textGenerationController == nil { + _textGenerationController = MediaTextGenerationController(media: media) { [weak self] type, generatedText in + guard let self else { return } + switch type { + case .altText: + self.mediaMetadata.alt = generatedText + case .caption: + self.mediaMetadata.caption = generatedText + } + self.reloadViewModel() + } + } + return _textGenerationController as! MediaTextGenerationController + } init(media: Media) { self.media = media @@ -327,11 +345,14 @@ final class MediaItemViewController: UITableViewController { private func editCaption() -> ((ImmuTableRow) -> ()) { return { [weak self] row in let editableRow = row as! EditableTextRow - self?.pushSettingsController(for: editableRow, hint: Strings.Hints.imageCaption, + let controller = self?.pushSettingsController(for: editableRow, hint: Strings.Hints.imageCaption, onValueChanged: { value in self?.mediaMetadata.caption = value self?.reloadViewModel() }) + if #available(iOS 26, *), let self, let controller { + self.textGenerationController.configure(controller, for: .caption) + } } } @@ -349,15 +370,19 @@ final class MediaItemViewController: UITableViewController { private func editAlt() -> ((ImmuTableRow) -> ()) { return { [weak self] row in let editableRow = row as! EditableTextRow - self?.pushSettingsController(for: editableRow, hint: Strings.Hints.imageAlt, + let controller = self?.pushSettingsController(for: editableRow, hint: Strings.Hints.imageAlt, onValueChanged: { value in self?.mediaMetadata.alt = value self?.reloadViewModel() }) + if #available(iOS 26, *), let self, let controller { + self.textGenerationController.configure(controller, for: .altText) + } } } - private func pushSettingsController(for row: EditableTextRow, hint: String? = nil, onValueChanged: @escaping SettingsTextChanged) { + @discardableResult + private func pushSettingsController(for row: EditableTextRow, hint: String? = nil, onValueChanged: @escaping SettingsTextChanged) -> SettingsTextViewController { let title = row.title let value = row.value let controller = SettingsTextViewController(text: value, placeholder: "\(title)...", hint: hint) @@ -366,6 +391,7 @@ final class MediaItemViewController: UITableViewController { controller.onValueChanged = onValueChanged navigationController?.pushViewController(controller, animated: true) + return controller } // MARK: - Sharing Logic @@ -417,7 +443,7 @@ extension MediaItemViewController { /// Provides some extra formatting for a Media asset's metadata, used /// to present it in the MediaItemViewController /// -private struct MediaMetadataPresenter { +struct MediaMetadataPresenter { let media: Media /// A String containing the pixel size of the asset (width X height) diff --git a/WordPress/Classes/ViewRelated/Media/MediaTextGenerationController.swift b/WordPress/Classes/ViewRelated/Media/MediaTextGenerationController.swift new file mode 100644 index 000000000000..cafc85dab7c9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/MediaTextGenerationController.swift @@ -0,0 +1,114 @@ +import UIKit +import SVProgressHUD +import WordPressData +import WordPressShared +import WordPressIntelligence + +@available(iOS 26, *) +@MainActor +final class MediaTextGenerationController { + + enum GenerationType { + case altText + case caption + } + + private let media: Media + private let onMetadataUpdated: (GenerationType, String) -> Void + + init(media: Media, onMetadataUpdated: @escaping (GenerationType, String) -> Void) { + self.media = media + self.onMetadataUpdated = onMetadataUpdated + } + + /// Configures a settings controller with a generate button + func configure(_ controller: SettingsTextViewController, for type: GenerationType) { + guard IntelligenceService.isSupported else { return } + + let button = makeGenerateButton(for: controller, type: type) + controller.navigationItem.rightBarButtonItem = button + } + + private func makeGenerateButton(for controller: SettingsTextViewController, type: GenerationType) -> UIBarButtonItem { + let button = UIBarButtonItem( + image: UIImage(systemName: "sparkles"), + style: .plain, + target: nil, + action: nil + ) + button.accessibilityLabel = Strings.generate + button.primaryAction = UIAction { [weak self, weak controller, weak button] _ in + guard let self, let controller, let button else { return } + self.handleGenerate(controller: controller, button: button, type: type) + } + return button + } + + private func handleGenerate(controller: SettingsTextViewController, button: UIBarButtonItem, type: GenerationType) { + setGenerating(true, button: button) + + Task { + do { + let generatedText = try await generateText(for: type) + controller.text = generatedText + onMetadataUpdated(type, generatedText) + } catch { + SVProgressHUD.showError(withStatus: error.localizedDescription) + } + setGenerating(false, button: button) + } + } + + private func generateText(for type: GenerationType) async throws -> String { + // Load image from media + guard let imageURL = media.absoluteThumbnailLocalURL ?? media.absoluteLocalURL, + let imageData = try? Data(contentsOf: imageURL), + let image = UIImage(data: imageData), + let cgImage = image.cgImage else { + throw NSError(domain: "MediaTextGenerationController", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Unable to load image for analysis" + ]) + } + + // Build metadata (without imageAnalysis - Vision analysis will be performed automatically) + let presenter = MediaMetadataPresenter(media: media) + let metadata = MediaMetadata( + filename: media.filename, + title: media.title, + caption: media.caption, + description: media.desc, + altText: media.alt, + fileType: presenter.fileType, + dimensions: presenter.dimensions, + imageAnalysis: nil // Will be populated automatically by convenience API + ) + + // Use convenience API that handles VisionKit analysis automatically + switch type { + case .altText: + return try await ImageAltTextGenerator().generate(cgImage: cgImage, metadata: metadata) + case .caption: + return try await ImageCaptionGenerator().generate(cgImage: cgImage, metadata: metadata) + } + } + + private func setGenerating(_ isGenerating: Bool, button: UIBarButtonItem) { + if isGenerating { + let indicator = UIActivityIndicatorView() + indicator.startAnimating() + indicator.frame = CGRect(origin: .zero, size: CGSize(width: 24, height: 24)) + button.customView = indicator + } else { + button.customView = nil + } + button.isEnabled = !isGenerating + } +} + +private enum Strings { + static let generate = NSLocalizedString( + "media.textGeneration.generate", + value: "Generate", + comment: "Accessibility label for the generate button in media alt text/caption editor" + ) +} diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index c136bda4175d..3cf82c08ebe3 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -9,6 +9,7 @@ import WordPressCore import WordPressCoreProtocols import WordPressData import WordPressShared +import WordPressIntelligence import CocoaLumberjack extension SupportDataProvider { @@ -510,7 +511,7 @@ extension SupportAttachment { fileprivate func summarize(_ text: String) async -> String { if #available(iOS 26.0, *) { do { - return try await IntelligenceService().summarizeSupportTicket(content: text) + return try await SupportTicketSummaryGenerator.execute(content: text) } catch { return text } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Services/TagSuggestionsService.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Services/TagSuggestionsService.swift index 50cc71242302..b0ce6989f274 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Services/TagSuggestionsService.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Services/TagSuggestionsService.swift @@ -1,6 +1,7 @@ import Foundation import WordPressData import WordPressShared +import WordPressIntelligence @MainActor final class TagSuggestionsService { @@ -31,7 +32,7 @@ final class TagSuggestionsService { try Task.checkCancellation() - return try await IntelligenceService().suggestTags( + return try await TagSuggestionGenerator().generate( post: postContent, siteTags: siteTags, postTags: postTags diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift index 668818775e53..0954e2db2b85 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift @@ -1,5 +1,6 @@ import SwiftUI import WordPressUI +import WordPressIntelligence import WordPressShared import DesignSystem @@ -42,7 +43,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..7e84cfd151a1 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift @@ -2,6 +2,8 @@ import SwiftUI import WordPressUI import DesignSystem import FoundationModels +import WordPressShared +import WordPressIntelligence @available(iOS 26, *) struct PostSettingsGenerateExcerptView: View { @@ -11,10 +13,10 @@ struct PostSettingsGenerateExcerptView: View { @Environment(\.dismiss) private var dismiss @AppStorage("jetpack_ai_generated_excerpt_style") - private var style: GenerationStyle = .engaging + private var style: WritingStyle = .engaging @AppStorage("jetpack_ai_generated_excerpt_length") - private var length: GeneratedContentLength = .medium + private var length: ContentLength = .medium @State private var results: [ExcerptGenerationResult.PartiallyGenerated] = [] @State private var isGenerating = false @@ -162,9 +164,9 @@ struct PostSettingsGenerateExcerptView: View { Slider( value: Binding( get: { Double(length.rawValue) }, - set: { length = GeneratedContentLength(rawValue: Int($0)) ?? .medium } + set: { length = ContentLength(rawValue: Int($0)) ?? .medium } ), - in: 0...Double(GeneratedContentLength.allCases.count - 1), + in: 0...Double(ContentLength.allCases.count - 1), step: 1 ) { Text(Strings.lengthSliderAccessibilityLabel) @@ -199,7 +201,7 @@ struct PostSettingsGenerateExcerptView: View { Spacer(minLength: 8) Picker(Strings.stylePickerAccessibilityLabel, selection: $style) { - ForEach(GenerationStyle.allCases, id: \.self) { style in + ForEach(WritingStyle.allCases, id: \.self) { style in Text(style.displayName) .tag(style) } @@ -230,10 +232,8 @@ struct PostSettingsGenerateExcerptView: View { generationTask = Task { do { - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: LanguageModelHelper.generateExcerptInstructions - ) + let generator = PostExcerptGenerator(length: length, style: style) + let session = generator.makeSession() self.session = session try await actuallyGenerateExcerpts(in: session) } catch { @@ -273,8 +273,8 @@ struct PostSettingsGenerateExcerptView: View { isGenerating = false } - let content = IntelligenceService().extractRelevantText(from: postContent) - let prompt = isLoadMore ? LanguageModelHelper.generateMoreOptionsPrompt : LanguageModelHelper.makeGenerateExcerptPrompt(content: content, length: length, style: style) + let generator = PostExcerptGenerator(length: length, style: style) + let prompt = isLoadMore ? PostExcerptGenerator.loadMorePrompt : await generator.makePrompt(content: postContent) 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/Controllers/ReaderPostActions/ReaderPostMenu.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift index eaa61addf0ea..12a8043b361a 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderPostActions/ReaderPostMenu.swift @@ -4,6 +4,7 @@ import SafariServices import SwiftUI import WordPressData import WordPressShared +import WordPressIntelligence struct ReaderPostMenu { let post: ReaderPost diff --git a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift index 7cf11dcdb604..800cda202956 100644 --- a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift @@ -1,7 +1,7 @@ import SwiftUI import WordPressUI import WordPressData -import FoundationModels +import WordPressIntelligence @available(iOS 26, *) struct ReaderSummarizePostView: View { @@ -72,13 +72,11 @@ struct ReaderSummarizePostView: View { do { let content = post.content ?? "" - let stream = await IntelligenceService().summarizePost(content: content) + let result = try await PostSummaryGenerator().generate(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 } diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 6694bab6af97..c3371f1299e6 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -4438,6 +4438,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; @@ -4467,6 +4468,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.automattic.Miniature; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -4492,6 +4494,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; @@ -4518,6 +4521,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.automattic.Miniature; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -4542,6 +4546,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1;