From 68ad421f78cd008772c0213e57d5b76939bbc1c0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 28 Nov 2025 15:46:44 -0500 Subject: [PATCH 01/13] Enable FF --- .../Intelligence/IntelligenceService.swift | 26 ++++++++++++++++--- RELEASE-NOTES.txt | 2 +- .../BuildInformation/FeatureFlag.swift | 7 +++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift index 66f386c49c9e..9a2a3383aaed 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift @@ -45,7 +45,7 @@ public actor IntelligenceService { // - `useCase: .contentTagging` is not recommended for arbitraty hashtags let instructions = """ - You are helping a WordPress user add tags to a post or a page. + \(makeLocaleInstructions())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) @@ -54,9 +54,12 @@ public actor IntelligenceService { **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. + - 2. Identify the language used in SITE_TAGS and POST_CONTENT + - 3. Generate a list of ten most relevant suggested tags based on POST_CONTENT and SITE_TAGS relevant to the content. **Requirements** + - You MUST generate tags in the same language as SITE_TAGS and POST_CONTENT + - Tags MUST match the formatting pattern and language of existing tags - Do not include any tags from EXISTING_POST_TAGS - If there are no relevant suggestions, returns an empty list - Do not produce any output other than the final list of tag @@ -101,9 +104,10 @@ public actor IntelligenceService { let content = extractRelevantText(from: content, ratio: 0.8) let instructions = """ - You are helping a WordPress user understand the content of a post. + \(makeLocaleInstructions())You are helping a WordPress user understand the content of a post. Generate a concise summary that captures the main points and key information. The summary should be clear, informative, and written in a neutral tone. + You MUST generate the summary in the same language as the post content. Do not include anything other than the summary in the response. """ @@ -124,10 +128,11 @@ public actor IntelligenceService { 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 + \(makeLocaleInstructions())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. """ @@ -155,6 +160,19 @@ public actor IntelligenceService { let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio return String((extract ?? post).prefix(Int(postSizeLimit))) } + + /// Generates locale-specific instructions for the language model. + /// + /// Following Apple's recommended approach for multilingual support: + /// https://developer.apple.com/documentation/foundationmodels/support-languages-and-locales-with-foundation-models + private nonisolated func makeLocaleInstructions(for locale: Locale = .current) -> String { + // Skip the locale phrase for U.S. English (Apple's recommendation) + if Locale.Language(identifier: "en_US").isEquivalent(to: locale.language) { + return "" + } + // Use the exact phrase format recommended by Apple to reduce hallucinations + return "The person's locale is \(locale.identifier).\n" + } } private extension Array where Element: Hashable { diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 1eb9adfb1ca9..cef50b8bdb9f 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,6 @@ 26.6 ----- - +* [**] [Intelligence] Expand AI-based features to more locales [#25034] 26.5 ----- diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 9f344b213e5f..16055bf9c4fc 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -1,5 +1,6 @@ import BuildSettingsKit import Foundation +import FoundationModels /// FeatureFlag exposes a series of features to be conditionally enabled on /// different builds. @@ -80,8 +81,10 @@ public enum FeatureFlag: Int, CaseIterable { case .newStats: return false case .intelligence: - let languageCode = Locale.current.language.languageCode?.identifier - return (languageCode ?? "en").hasPrefix("en") + guard #available(iOS 26, *) else { + return false + } + return SystemLanguageModel.default.supportsLocale() case .newSupport: return false case .nativeBlockInserter: From aa1304da3a6d272fc76c7718fe835acf5d22d97e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 3 Dec 2025 15:11:18 -0500 Subject: [PATCH 02/13] Add unit tests for testing localization --- .../EXAMPLE_TEST_OUTPUT.md | 210 ++++++ .../IntelligenceExcerptGenerationTests.swift | 602 ++++++++++++++++++ .../IntelligencePostSummaryTests.swift | 284 +++++++++ .../IntelligenceSuggestedTagsTests.swift | 352 ++++++++++ .../IntelligenceTestHelpers.swift | 118 ++++ .../README_LOCALE_TESTS.md | 193 ++++++ 6 files changed, 1759 insertions(+) create mode 100644 Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md create mode 100644 Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift create mode 100644 Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift create mode 100644 Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift create mode 100644 Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift create mode 100644 Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md diff --git a/Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md b/Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md new file mode 100644 index 000000000000..3f53269dfc80 --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md @@ -0,0 +1,210 @@ +# Example Test Output + +This document shows what the structured test output looks like when running intelligence tests. + +## Tag Suggestions Output + +``` +════════════════════════════════════════════════════════════════════════════════ +🏷️ Spanish Content → Spanish Site Tags +════════════════════════════════════════════════════════════════════════════════ + +📥 INPUT + Content: La paella valenciana es uno de los platos más emblemáticos de la gastronomía española... + Content Language: Spanish + Site Tags: recetas, cocina-española, gastronomía, comida-mediterránea, platos-tradicionales + +📤 OUTPUT + Generated Tags (8): + 1. arroz + 2. paella-valenciana + 3. azafrán + 4. cocina-tradicional + 5. receta-española + 6. comida-casera + 7. arroz-bomba + 8. sofrito + +✓ VERIFICATION CHECKLIST + • All tags are in Spanish + • Tags use lowercase-hyphenated format (e.g., 'cocina-española') + • Tags are relevant to paella/Spanish cooking + • No duplicate tags + +════════════════════════════════════════════════════════════════════════════════ +``` + +## Post Summary Output + +``` +════════════════════════════════════════════════════════════════════════════════ +📝 Spanish Post Summary +════════════════════════════════════════════════════════════════════════════════ + +📥 INPUT + Content Language: Spanish + Content Type: Recipe/Cooking + Preview: La paella valenciana es uno de los platos más emblemáticos de la gastronomía española... + Length: 823 characters + +📤 OUTPUT + La paella valenciana es un plato emblemático de la gastronomía española que combina + arroz, azafrán y diversos ingredientes como pollo, conejo y verduras. El éxito de + este plato tradicional radica en la técnica del sofrito inicial y el punto preciso + del arroz. El uso de caldo casero de calidad y arroz bomba es fundamental, junto + con un control cuidadoso del fuego para lograr el socarrat, esa capa crujiente + característica que se forma en el fondo de la paellera. + ... (2 more lines) + Length: 543 characters, 87 words + +✓ VERIFICATION CHECKLIST + • Summary is in Spanish + • Captures main points about paella + • Concise and informative + • Neutral tone + +════════════════════════════════════════════════════════════════════════════════ +``` + +## Excerpt Generation Output + +``` +════════════════════════════════════════════════════════════════════════════════ +✍️ Spanish Excerpt - Short Engaging +════════════════════════════════════════════════════════════════════════════════ + +📥 INPUT + Content Language: Spanish + Target Length: 20-40 words + Style: Engaging + Preview: La auténtica tortilla española es mucho más que simplemente huevos y patatas... + +📤 OUTPUT + Generated 3 excerpt variations: + + [1] (32 words) + Descubre los secretos de la auténtica tortilla española: el arte de lograr patatas + perfectamente cremosas y huevos jugosos. Aprende la técnica tradicional que las + abuelas españolas han perfeccionado durante generaciones. + + [2] (28 words) + La tortilla española perfecta requiere paciencia y técnica. Conoce el equilibrio + ideal entre patatas tiernas, huevos cremosos y esa capa dorada exterior que hace + la diferencia. + + [3] (35 words) + ¿Quieres dominar el arte de la tortilla española? Te revelamos el secreto mejor + guardado: la temperatura exacta del aceite que transforma simples ingredientes en + una obra maestra culinaria con textura cremosa irresistible. + +✓ VERIFICATION CHECKLIST + • All excerpts are in Spanish + • Each excerpt is 20-40 words long + • Tone is engaging and compelling + • Focus on value, not just summary + • No ellipsis (...) at the end + • Works as standalone content + +════════════════════════════════════════════════════════════════════════════════ +``` + +## Comparison Table Output (All Styles) + +``` +════════════════════════════════════════════════════════════════════════════════ +📊 Spanish Excerpts - All Styles Comparison (Medium Length) +════════════════════════════════════════════════════════════════════════════════ + +Style │ Length │ Preview +─────────────────┼─────────────┼────────────────────────────────────────────────────────────── +Engaging │ 58 words │ Descubre los secretos de la auténtica tortilla españo... +Conversational │ 62 words │ ¿Sabes qué hace especial a una tortilla española? Te l... +Witty │ 55 words │ La tortilla española: más complicada que decidir quié... +Formal │ 67 words │ La preparación de la tortilla española requiere un co... +Professional │ 64 words │ La tortilla española constituye un elemento fundament... + +✓ VERIFICATION CHECKLIST + • Each style has distinct tone + • All excerpts are 50-70 words + • All excerpts are in Spanish + • Content is relevant to Spanish tortilla recipe + +════════════════════════════════════════════════════════════════════════════════ +``` + +## Comparison Table Output (All Lengths) + +``` +════════════════════════════════════════════════════════════════════════════════ +📊 English Excerpts - All Lengths Comparison (Engaging Style) +════════════════════════════════════════════════════════════════════════════════ + +Length │ Expected │ Actual │ Preview +─────────┼─────────────┼─────────┼────────────────────────────────────────────────────── +Short │ 20-40 │ 35 │ Quantum computing represents a paradigm shift in comp... +Medium │ 50-70 │ 63 │ Quantum computers leverage qubits in superposition, e... +Long │ 120-180 │ 156 │ Unlike classical computers using binary bits, quantum... + +✓ VERIFICATION CHECKLIST + • Each length matches expected word count range + • All excerpts are in English + • Tone is engaging throughout + • Content is relevant to quantum computing + +════════════════════════════════════════════════════════════════════════════════ +``` + +## Benefits of Structured Output + +### 1. **Clear Sections** +- Input parameters clearly separated from output +- Easy to see what was tested + +### 2. **Verification Checklists** +- Specific criteria to check +- Reduces manual verification effort +- Ensures consistency + +### 3. **Comparison Tables** +- Side-by-side comparison of multiple results +- Easy to spot differences in style/length +- Quick overview of variations + +### 4. **Visual Separators** +- Clean, professional appearance +- Easy to scan in console output +- Clear test boundaries + +### 5. **Metadata** +- Word/character counts +- Content previews +- Language indicators + +## Usage Pattern + +All tests now follow this pattern: + +```swift +@Test("Spanish content with Spanish site tags") +func spanishContentSpanishTags() async throws { + // 1. Execute the test + let tags = try await suggestTags(...) + + // 2. Print structured results + IntelligenceTestHelpers.printTagResults( + title: "Spanish Content → Spanish Site Tags", + input: .init(...), + output: tags, + expectations: [ + "All tags are in Spanish", + "Tags use lowercase-hyphenated format", + // ... + ] + ) + + // 3. Optional assertions + #expect(!tags.isEmpty) +} +``` + +This makes manual verification quick and efficient while maintaining clear documentation of expected behavior. diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift new file mode 100644 index 000000000000..f1cad786299b --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift @@ -0,0 +1,602 @@ +import Testing +import FoundationModels +@testable import WordPressShared + +/// Tests for excerpt generation with different locales and languages. +/// +/// These tests verify that excerpts are generated correctly for content in various +/// languages and match the specified length and style requirements. +/// +/// The entire test suite is disabled by default and should be run manually to verify +/// the generated excerpts meet quality standards. +@Suite //(.disabled("Manual tests - Run individually to test excerpt generation")) +struct IntelligenceExcerptGenerationTests { + + // MARK: - Spanish Content Excerpts + + @Test("Short engaging Spanish excerpt") + func spanishShortEngaging() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.spanishRecipePost, + length: .short, + style: .engaging + ) + + IntelligenceTestHelpers.printExcerptResults( + title: "Spanish Excerpt - Short Engaging", + input: .init( + contentLanguage: "Spanish", + targetLength: "20-40 words", + style: "Engaging", + contentPreview: IntelligenceTestHelpers.truncate(ExcerptTestData.spanishRecipePost) + ), + output: excerpts, + expectations: [ + "All excerpts are in Spanish", + "Each excerpt is 20-40 words long", + "Tone is engaging and compelling", + "Focus on value, not just summary", + "No ellipsis (...) at the end", + "Works as standalone content" + ] + ) + } + + @Test("Medium professional Spanish excerpt") + func spanishMediumProfessional() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.spanishTechPost, + length: .medium, + style: .professional + ) + + print("Medium Professional Spanish Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 50-70 words, professional tone, in Spanish") + } + + @Test("Long conversational Spanish excerpt") + func spanishLongConversational() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.spanishTravelPost, + length: .long, + style: .conversational + ) + + print("Long Conversational Spanish Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 120-180 words, conversational tone, in Spanish") + } + + // MARK: - English Content Excerpts + + @Test("Short witty English excerpt") + func englishShortWitty() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.englishTechPost, + length: .short, + style: .witty + ) + + print("Short Witty English Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 20-40 words, witty tone, in English") + } + + @Test("Medium formal English excerpt") + func englishMediumFormal() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.englishAcademicPost, + length: .medium, + style: .formal + ) + + print("Medium Formal English Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 50-70 words, formal tone, in English") + } + + @Test("Long engaging English excerpt") + func englishLongEngaging() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.englishStoryPost, + length: .long, + style: .engaging + ) + + print("Long Engaging English Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 120-180 words, engaging tone, in English") + } + + // MARK: - French Content Excerpts + + @Test("Medium engaging French excerpt") + func frenchMediumEngaging() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.frenchCulturePost, + length: .medium, + style: .engaging + ) + + print("Medium Engaging French Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 50-70 words, engaging tone, in French") + } + + @Test("Short professional French excerpt") + func frenchShortProfessional() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.frenchBusinessPost, + length: .short, + style: .professional + ) + + print("Short Professional French Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 20-40 words, professional tone, in French") + } + + // MARK: - Japanese Content Excerpts + + @Test("Medium conversational Japanese excerpt") + func japaneseMediumConversational() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.japaneseCookingPost, + length: .medium, + style: .conversational + ) + + print("Medium Conversational Japanese Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Character count: \(excerpt.count)") + } + + print("\nExpected: Appropriate length for Japanese, conversational tone") + } + + @Test("Short formal Japanese excerpt") + func japaneseShortFormal() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.japaneseBusinessPost, + length: .short, + style: .formal + ) + + print("Short Formal Japanese Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Character count: \(excerpt.count)") + } + + print("\nExpected: Appropriate length for Japanese, formal tone") + } + + // MARK: - German Content Excerpts + + @Test("Medium professional German excerpt") + func germanMediumProfessional() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.germanTechPost, + length: .medium, + style: .professional + ) + + print("Medium Professional German Excerpts:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + print("Word count: \(excerpt.split(separator: " ").count)") + } + + print("\nExpected: 50-70 words, professional tone, in German") + } + + // MARK: - Comprehensive Tests + + @Test("All styles for Spanish content") + func spanishAllStyles() async throws { + let content = ExcerptTestData.spanishRecipePost + var rows: [[String]] = [] + + for style in GenerationStyle.allCases { + let excerpts = try await generateExcerpts( + content: content, + length: .medium, + style: style + ) + + // Use first excerpt for comparison + let excerpt = excerpts.first ?? "" + let wordCount = excerpt.split(separator: " ").count + let preview = IntelligenceTestHelpers.truncate(excerpt, length: 60) + + rows.append([ + style.displayName, + "\(wordCount) words", + preview + ]) + } + + IntelligenceTestHelpers.printComparisonTable( + title: "Spanish Excerpts - All Styles Comparison (Medium Length)", + headers: ["Style", "Length", "Preview"], + rows: rows, + expectations: [ + "Each style has distinct tone", + "All excerpts are 50-70 words", + "All excerpts are in Spanish", + "Content is relevant to Spanish tortilla recipe" + ] + ) + } + + @Test("All lengths for English content") + func englishAllLengths() async throws { + let content = ExcerptTestData.englishTechPost + var rows: [[String]] = [] + + for length in GeneratedContentLength.allCases { + let excerpts = try await generateExcerpts( + content: content, + length: length, + style: .engaging + ) + + // Use first excerpt for comparison + let excerpt = excerpts.first ?? "" + let wordCount = excerpt.split(separator: " ").count + let preview = IntelligenceTestHelpers.truncate(excerpt, length: 60) + + let expectedRange: String + switch length { + case .short: expectedRange = "20-40" + case .medium: expectedRange = "50-70" + case .long: expectedRange = "120-180" + } + + rows.append([ + length.displayName, + expectedRange, + "\(wordCount)", + preview + ]) + } + + IntelligenceTestHelpers.printComparisonTable( + title: "English Excerpts - All Lengths Comparison (Engaging Style)", + headers: ["Length", "Expected", "Actual", "Preview"], + rows: rows, + expectations: [ + "Each length matches expected word count range", + "All excerpts are in English", + "Tone is engaging throughout", + "Content is relevant to quantum computing" + ] + ) + } + + // MARK: - HTML Content Tests + + @Test("Spanish HTML content") + func spanishHTMLContent() async throws { + let excerpts = try await generateExcerpts( + content: ExcerptTestData.spanishHTMLPost, + length: .medium, + style: .engaging + ) + + print("Excerpts from Spanish HTML content:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + } + + print("\nExpected: Clean excerpts without HTML tags, in Spanish") + } + + // MARK: - Edge Cases + + @Test("Very short content") + func veryShortContent() async throws { + let shortContent = "La inteligencia artificial está transformando nuestro mundo." + + let excerpts = try await generateExcerpts( + content: shortContent, + length: .short, + style: .engaging + ) + + print("Excerpts from very short content:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + } + + print("\nExpected: Reasonable excerpts even with limited source content") + } + + @Test("Content with special characters") + func specialCharacters() async throws { + let content = """ + ¿Sabías que el símbolo @ tiene más de 500 años? Este carácter, conocido como "arroba" + en español, se utilizaba originalmente en documentos comerciales para indicar "al precio de". + Hoy en día, es imposible imaginar el correo electrónico o las redes sociales sin él. + """ + + let excerpts = try await generateExcerpts( + content: content, + length: .short, + style: .witty + ) + + print("Excerpts with special characters:") + excerpts.enumerated().forEach { index, excerpt in + print("\n[\(index + 1)] \(excerpt)") + } + + print("\nExpected: Proper handling of Spanish special characters") + } + + // MARK: - Helper Methods + + private func generateExcerpts( + content: String, + length: GeneratedContentLength, + style: GenerationStyle + ) async throws -> [String] { + if #available(iOS 26, *) { + let service = IntelligenceService() + let extractedContent = service.extractRelevantText(from: content) + + let instructions = LanguageModelHelper.generateExcerptInstructions + let prompt = LanguageModelHelper.makeGenerateExcerptPrompt( + content: extractedContent, + length: length, + style: style + ) + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let response = try await session.respond( + to: prompt, + generating: GeneratedExcerpts.self, + options: GenerationOptions(temperature: 0.7) + ) + + return response.content.excerpts + } else { + throw TestError.unsupportedIOSVersion + } + } +} + +// MARK: - Test Errors + +private enum TestError: Error, CustomStringConvertible { + case unsupportedIOSVersion + + var description: String { + switch self { + case .unsupportedIOSVersion: + return "Excerpt generation requires iOS 26 or later. Current iOS version does not support these features." + } + } +} + +// MARK: - Supporting Types + +@available(iOS 26, *) +@Generable +private struct GeneratedExcerpts { + @Guide(description: "Three different excerpt variations") + var excerpts: [String] +} + +// MARK: - Test Data + +private enum ExcerptTestData { + static let spanishRecipePost = """ + La auténtica tortilla española es mucho más que simplemente huevos y patatas. Es un arte + culinario que requiere paciencia, técnica y los ingredientes adecuados. La clave está en + lograr el equilibrio perfecto: patatas tiernas pero no deshechas, huevos jugosos en el centro, + y esa capa exterior ligeramente dorada que aporta textura. + + El secreto mejor guardado de las abuelas españolas es la temperatura del aceite. Debe estar + lo suficientemente caliente para cocinar las patatas, pero no tanto como para dorarlas rápidamente. + Este proceso lento permite que las patatas se impregnen del aceite de oliva, creando esa textura + cremosa característica. + + Algunos puristas insisten en que solo debe llevar patatas, huevos, cebolla y sal. Otros + defienden versiones más innovadoras con chorizo, pimientos o incluso espinacas. Sin embargo, + la receta tradicional valenciana ha resistido el paso del tiempo por una razón: su simplicidad + permite que brillen los sabores fundamentales. + """ + + static let spanishTechPost = """ + La inteligencia artificial generativa ha revolucionado la forma en que interactuamos con la + tecnología. Modelos de lenguaje como GPT-4 y Claude han demostrado capacidades sorprendentes + en tareas que van desde la redacción creativa hasta el análisis de código complejo. + + Sin embargo, esta transformación digital plantea importantes cuestiones éticas. ¿Cómo garantizamos + que estos sistemas sean justos e imparciales? ¿Qué medidas debemos implementar para proteger + la privacidad de los datos? ¿Cómo equilibramos la automatización con la preservación del empleo? + + Los expertos coinciden en que necesitamos un marco regulatorio robusto que fomente la innovación + mientras protege los derechos fundamentales. La Unión Europea ha dado un paso importante con su + Ley de IA, estableciendo un precedente para otras regiones del mundo. + """ + + static let spanishTravelPost = """ + Sevilla en primavera es una experiencia sensorial incomparable. Las calles estrechas del barrio + de Santa Cruz se llenan del aroma de azahar, mientras el sonido de las guitarras flamencas + resuena desde los patios escondidos. Durante la Feria de Abril, la ciudad se transforma en un + espectáculo de color, música y tradición que deja sin aliento a los visitantes. + + Pasear por el Real Alcázar al atardecer es como retroceder en el tiempo. Los jardines moriscos, + con sus fuentes burbujeantes y naranjos centenarios, ofrecen un oasis de tranquilidad en medio + del bullicio urbano. La mezcla de arquitectura mudéjar, gótica y renacentista cuenta la rica + historia de una ciudad que ha sido encrucijada de culturas durante milenios. + + No se puede visitar Sevilla sin probar las tapas auténticas. Olvidaos de los lugares turísticos + y aventuraos en los bares de barrio donde los locales se congregan. Un montadito de pringá, unas + espinacas con garbanzos, o el clásico salmorejo cordobés acompañados de un fino bien frío son + la verdadera esencia de la cultura sevillana. + """ + + static let spanishHTMLPost = """ + +

Los Beneficios del Yoga para la Salud Mental

+ + + +

El yoga no es solo una práctica física; es una disciplina holística que integra cuerpo, + mente y espíritu. Numerosos estudios científicos han demostrado sus efectos positivos en + la reducción del estrés, la ansiedad y la depresión.

+ + + + + + """ + + static let englishTechPost = """ + Quantum computing represents a paradigm shift in how we approach computational problems. Unlike + classical computers that use bits (0s and 1s), quantum computers leverage qubits that can exist + in superposition, simultaneously representing multiple states. + + This fundamental difference enables quantum computers to tackle problems that are intractable + for classical machines. Drug discovery, cryptography, optimization, and climate modeling are + just a few domains poised for revolutionary breakthroughs. + + However, significant challenges remain. Quantum systems are incredibly fragile, requiring + near-absolute-zero temperatures and isolation from environmental interference. Error correction + is another major hurdle, as quantum states are prone to decoherence. + """ + + static let englishAcademicPost = """ + The phenomenon of linguistic relativity, often referred to as the Sapir-Whorf hypothesis, + posits that the structure of a language influences its speakers' worldview and cognition. + While the strong version of this hypothesis has been largely discredited, contemporary research + suggests more nuanced relationships between language and thought. + + Recent studies in cognitive linguistics have demonstrated that language can indeed affect + perception and categorization, particularly in domains like color perception, spatial reasoning, + and temporal cognition. However, these effects are context-dependent and vary significantly + across different cognitive domains. + + Cross-linguistic research continues to provide valuable insights into the universal and + language-specific aspects of human cognition, challenging researchers to refine their + theoretical frameworks and methodological approaches. + """ + + static let englishStoryPost = """ + The old lighthouse keeper had seen many storms in his forty years tending the beacon, but + none quite like the tempest that rolled in that October evening. Dark clouds gathered on + the horizon like an invading army, their edges tinged with an unsettling green hue. + + As the first drops of rain pelted the lighthouse windows, Magnus checked the lamp one final + time. The beam cut through the gathering darkness, a lifeline for any vessels brave or foolish + enough to be out on such a night. He'd heard the coastguard warnings on the radio—winds + exceeding 90 miles per hour, waves reaching heights of 30 feet. + + Down in the keeper's quarters, Magnus brewed strong coffee and settled into his worn leather + chair. Outside, the wind howled like a wounded beast, but within these thick stone walls, + he felt safe. This lighthouse had withstood two centuries of nature's fury; it would stand + through one more night. + """ + + static let frenchCulturePost = """ + Le café français est bien plus qu'une simple boisson; c'est une institution culturelle qui + incarne l'art de vivre à la française. Depuis les cafés littéraires de Saint-Germain-des-Prés + fréquentés par Sartre et de Beauvoir jusqu'aux bistrots de quartier où les habitués se + retrouvent chaque matin, ces établissements sont le cœur battant de la vie sociale française. + + L'architecture des cafés parisiens, avec leurs terrasses caractéristiques et leurs décors + Art nouveau ou Art déco, raconte l'histoire de la ville. Chaque café a son caractère unique, + son ambiance particulière, et ses réguliers qui y viennent non seulement pour le café, + mais surtout pour la conversation, l'observation, et cette délicieuse pause dans le rythme + effréné de la vie moderne. + """ + + static let frenchBusinessPost = """ + La transformation numérique des entreprises françaises s'accélère, portée par la nécessité + de rester compétitives dans un marché mondialisé. Les PME, longtemps réticentes à adopter + de nouvelles technologies, reconnaissent désormais l'importance cruciale de l'innovation + digitale pour leur survie et leur croissance. + + Les investissements dans l'intelligence artificielle, le cloud computing, et la cybersécurité + augmentent exponentiellement. Le gouvernement français soutient cette transition avec des + programmes d'accompagnement et des incitations fiscales, conscient que la compétitivité + future du pays en dépend. + """ + + static let japaneseCookingPost = """ + 日本料理における包丁の技術は、単なる食材の切断以上の意味を持ちます。それは料理人の + 心と技が一体となる瞬間であり、何年もの修行を通じて磨かれる芸術形態です。 + + 寿司職人が魚を捌く際の手つきには、無駄な動きが一切ありません。一つ一つの動作が計算され、 + 食材の繊維を理解し、その特性を最大限に引き出すための最適な方法が選ばれています。 + この技術は「包丁さばき」と呼ばれ、日本料理の根幹を成すものです。 + + 家庭料理においても、適切な包丁の使い方を学ぶことは重要です。正しい持ち方、切り方を + マスターすることで、料理の効率が上がるだけでなく、食材の味わいも向上します。 + 包丁に対する敬意と理解が、美味しい料理への第一歩なのです。 + """ + + static let japaneseBusinessPost = """ + 日本企業のグローバル展開において、文化的な適応能力が成功の鍵となっています。 + 従来の日本的経営手法を維持しながら、現地の商習慣や消費者ニーズに柔軟に対応する + バランス感覚が求められています。 + + 特に人材マネジメントの分野では、年功序列や終身雇用といった伝統的なシステムと、 + 成果主義やダイバーシティといった現代的な価値観の融合が課題となっています。 + 多くの企業が試行錯誤を重ねながら、新しい時代に適した経営モデルを模索しています。 + """ + + static let germanTechPost = """ + 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. + """ +} diff --git a/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift new file mode 100644 index 000000000000..67dab9bf6fd2 --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift @@ -0,0 +1,284 @@ +import Testing +@testable import WordPressShared + +/// Tests for post summarization with different locales and languages. +/// +/// These tests verify that post summaries are generated correctly for content in various +/// languages. The entire test suite is disabled by default and should be run manually +/// to verify the generated summaries match expectations. +/// +/// To test different system locales: +/// 1. Change the scheme's Application Language in Xcode +/// 2. Run the test suite manually +/// 3. Verify the generated summaries match the expected language and tone +@Suite(.disabled("Manual tests - Run individually to test post summaries")) +struct IntelligencePostSummaryTests { + + @Test("Summarize Spanish post") + func spanishContent() async throws { + let summary = try await summarizePost(content: TestData.spanishPost) + + IntelligenceTestHelpers.printSummaryResults( + "Spanish Post Summary", + summary: summary, + language: "Spanish", + expectations: [ + "Summary is in Spanish", + "Captures main points about paella", + "Concise and informative", + "Neutral tone" + ] + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @Test("Summarize English post") + func englishContent() async throws { + let summary = try await summarizePost(content: TestData.englishPost) + + IntelligenceTestHelpers.printSummaryResults( + "English Post Summary", + summary: summary, + language: "English", + expectations: [ + "Summary is in English", + "Captures main points about sourdough bread", + "Concise and informative", + "Neutral tone" + ] + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @Test("Summarize French post") + func frenchContent() async throws { + let summary = try await summarizePost(content: TestData.frenchPost) + + IntelligenceTestHelpers.printSummaryResults( + "French Post Summary", + summary: summary, + language: "French", + expectations: [ + "Summary is in French", + "Captures main points about French cuisine", + "Concise and informative", + "Neutral tone" + ] + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @Test("Summarize Japanese post") + func japaneseContent() async throws { + let summary = try await summarizePost(content: TestData.japanesePost) + + IntelligenceTestHelpers.printSummaryResults( + "Japanese Post Summary", + summary: summary, + language: "Japanese", + expectations: [ + "Summary is in Japanese", + "Captures main points about dashi", + "Concise and informative", + "Neutral tone" + ] + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @Test("Summarize HTML-heavy Spanish post") + func spanishHTMLContent() async throws { + let summary = try await summarizePost(content: TestData.spanishPostWithHTML) + + IntelligenceTestHelpers.printSummaryResults( + "Spanish HTML Content Summary", + summary: summary, + language: "Spanish", + expectations: [ + "Summary is in Spanish", + "HTML tags properly handled/ignored", + "Content extracted correctly", + "Relevant to paella recipe" + ] + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @Test("Reader summary for Spanish article") + func readerSummarySpanishContent() async throws { + let summary = try await summarizePost(content: TestData.spanishReaderArticle) + + IntelligenceTestHelpers.printSummaryResults( + "Spanish Reader Article Summary", + summary: summary, + language: "Spanish", + expectations: [ + "Summary is in Spanish", + "Concise and suitable for Reader", + "Captures climate change impact on Mediterranean", + "Professional and informative tone" + ] + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + @Test("Reader summary for English article") + func readerSummaryEnglishContent() async throws { + let summary = try await summarizePost(content: TestData.englishReaderArticle) + + IntelligenceTestHelpers.printSummaryResults( + "English Reader Article Summary", + summary: summary, + language: "English", + expectations: [ + "Summary is in English", + "Concise and suitable for Reader", + "Captures quantum computing advances", + "Professional and informative tone" + ] + ) + + #expect(!summary.isEmpty, "Summary should not be empty") + } + + // MARK: - Helper Methods + + private func summarizePost(content: String) async throws -> String { + if #available(iOS 26, *) { + let service = IntelligenceService() + var summary = "" + + for try await chunk in await service.summarizePost(content: content) { + summary = chunk.content + print(chunk.content, terminator: "") + } + + return summary + } else { + throw TestError.unsupportedIOSVersion + } + } +} + +// MARK: - Test Errors + +private enum TestError: Error, CustomStringConvertible { + case unsupportedIOSVersion + + var description: String { + switch self { + case .unsupportedIOSVersion: + return "Post summarization requires iOS 26 or later. Current iOS version does not support these features." + } + } +} + +// MARK: - Test Data + +private enum TestData { + static let spanishPost = """ + La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. + Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad + de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. + + La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. + El azafrán no solo aporta ese característico color dorado, sino también un sabor único + e inconfundible. + + Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la + variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para + conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. + """ + + static let spanishPostWithHTML = """ + +

Receta Auténtica de Paella Valenciana

+ + + +

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

+ + + +

Ingredientes Principales

+ + + + + + + +

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

+ + """ + + static let spanishReaderArticle = """ + 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. + """ + + static let englishPost = """ + Sourdough bread has experienced a remarkable revival in recent years, with home bakers + around the world rediscovering this ancient craft. The natural fermentation process + creates a distinctive tangy flavor and numerous health benefits. + + The key to successful sourdough lies in maintaining a healthy starter culture. This + living mixture of flour and water harbors wild yeast and beneficial bacteria that + work together to leaven the bread and develop complex flavors. + + Temperature and timing are crucial factors. The fermentation process can take anywhere + from 12 to 24 hours, depending on ambient temperature and the activity of your starter. + """ + + static let englishReaderArticle = """ + 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. + """ + + static let frenchPost = """ + 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. + """ + + static let japanesePost = """ + 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 + この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 + + 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 + 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 + + 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 + インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 + """ +} diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift new file mode 100644 index 000000000000..d62d04f158c4 --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift @@ -0,0 +1,352 @@ +import Testing +@testable import WordPressShared + +/// Tests for tag suggestion with different locales and languages. +/// +/// These tests verify that tag suggestions work correctly with content in various +/// languages and locale settings. The entire test suite is disabled by default +/// and should be run manually to verify the generated tags match expectations. +/// +/// To test different system locales: +/// 1. Change the scheme's Application Language in Xcode +/// 2. Run the test suite manually +/// 3. Verify the generated tags match the expected language and format +@Suite(.disabled("Manual tests - Run individually to test tag suggestions")) +struct IntelligenceSuggestedTagsTests { + + @Test("Spanish content with Spanish site tags") + func spanishContentSpanishTags() async throws { + let tags = try await suggestTags( + post: TestData.spanishPost, + siteTags: TestData.spanishSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "Spanish Content → Spanish Site Tags", + tags: tags, + language: "Spanish", + expectations: [ + "All tags in Spanish", + "Lowercase-hyphenated format", + "Relevant to paella/cooking" + ] + ) + } + + @Test("Spanish content with English site tags") + func spanishContentEnglishTags() async throws { + let tags = try await suggestTags( + post: TestData.spanishPost, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "Spanish Content → English Site Tags", + tags: tags, + language: "English", + expectations: [ + "All tags in English (matching site tags)", + "Lowercase-hyphenated format", + "Relevant to content" + ] + ) + } + + @Test("English content with Spanish site tags") + func englishContentSpanishTags() async throws { + let tags = try await suggestTags( + post: TestData.englishPost, + siteTags: TestData.spanishSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "English Content → Spanish Site Tags", + tags: tags, + language: "Spanish", + expectations: [ + "All tags in Spanish (matching site tags)", + "Lowercase-hyphenated format", + "Relevant to sourdough/baking" + ] + ) + } + + @Test("Mixed language content") + func mixedLanguageContent() async throws { + let tags = try await suggestTags( + post: TestData.mixedLanguagePost, + siteTags: TestData.englishSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "Mixed Language Content", + tags: tags, + language: "Dominant language", + expectations: [ + "Tags match dominant language in content", + "Relevant to Mediterranean diet" + ] + ) + } + + @Test("French content") + func frenchContent() async throws { + let tags = try await suggestTags( + post: TestData.frenchPost, + siteTags: TestData.frenchSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "French Content → French Site Tags", + tags: tags, + language: "French", + expectations: [ + "All tags in French", + "Lowercase-hyphenated format", + "Relevant to French cuisine" + ] + ) + } + + @Test("Japanese content") + func japaneseContent() async throws { + let tags = try await suggestTags( + post: TestData.japanesePost, + siteTags: TestData.japaneseSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "Japanese Content → Japanese Site Tags", + tags: tags, + language: "Japanese", + expectations: [ + "All tags in Japanese", + "Relevant to Japanese cuisine/dashi" + ] + ) + } + + @Test("Existing post tags should be excluded") + func excludeExistingTags() async throws { + let existingTags = ["recetas", "cocina"] + let tags = try await suggestTags( + post: TestData.spanishPost, + siteTags: TestData.spanishSiteTags, + postTags: existingTags + ) + + IntelligenceTestHelpers.printTagResults( + "Exclude Existing Tags: \(existingTags.joined(separator: ", "))", + tags: tags, + language: "Spanish", + expectations: [ + "Does NOT include: \(existingTags.joined(separator: ", "))", + "All new tags in Spanish", + "Relevant to content" + ] + ) + + #expect(!tags.contains { existingTags.contains($0) }) + } + + @Test("Empty site tags") + func emptySiteTags() async throws { + let tags = try await suggestTags( + post: TestData.spanishPost, + siteTags: [], + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "No Site Tags Context", + tags: tags, + language: "Spanish", + expectations: [ + "Tags in Spanish (content language)", + "Generated from content alone" + ] + ) + } + + @Test("Very short content") + func shortContent() async throws { + let tags = try await suggestTags( + post: "Deliciosa receta de gazpacho andaluz.", + siteTags: TestData.spanishSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "Very Short Content", + tags: tags, + language: "Spanish", + expectations: [ + "Tags in Spanish", + "Relevant despite short content" + ] + ) + } + + @Test("Very long content") + func longContent() async throws { + let longContent = String(repeating: TestData.spanishPost + "\n\n", count: 5) + let tags = try await suggestTags( + post: longContent, + siteTags: TestData.spanishSiteTags, + postTags: [] + ) + + IntelligenceTestHelpers.printTagResults( + "Very Long Content (Truncated)", + tags: tags, + language: "Spanish", + expectations: [ + "Tags in Spanish", + "Still relevant despite truncation" + ] + ) + } + + // MARK: - Helper Methods + + private func suggestTags(post: String, siteTags: [String], postTags: [String]) async throws -> [String] { + if #available(iOS 26, *) { + return try await IntelligenceService().suggestTags( + post: post, + siteTags: siteTags, + postTags: postTags + ) + } else { + throw TestError.unsupportedIOSVersion + } + } +} + +// MARK: - Test Errors + +private enum TestError: Error, CustomStringConvertible { + case unsupportedIOSVersion + + var description: String { + switch self { + case .unsupportedIOSVersion: + return "Tag suggestion requires iOS 26 or later. Current iOS version does not support these features." + } + } +} + +// MARK: - Test Data + +private enum TestData { + // MARK: Spanish Content + + static let spanishPost = """ + La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. + Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad + de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. + + La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. + El azafrán no solo aporta ese característico color dorado, sino también un sabor único + e inconfundible. + + Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la + variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para + conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. + """ + + static let spanishSiteTags = [ + "recetas", + "cocina-española", + "gastronomía", + "comida-mediterránea", + "platos-tradicionales" + ] + + // MARK: English Content + + static let englishPost = """ + Sourdough bread has experienced a remarkable revival in recent years, with home bakers + around the world rediscovering this ancient craft. The natural fermentation process + creates a distinctive tangy flavor and numerous health benefits. + + The key to successful sourdough lies in maintaining a healthy starter culture. This + living mixture of flour and water harbors wild yeast and beneficial bacteria that + work together to leaven the bread and develop complex flavors. + + Temperature and timing are crucial factors. The fermentation process can take anywhere + from 12 to 24 hours, depending on ambient temperature and the activity of your starter. + """ + + static let englishSiteTags = [ + "baking", + "bread-making", + "recipes", + "sourdough", + "homemade" + ] + + // MARK: French Content + + static let frenchPost = """ + 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. + """ + + static let frenchSiteTags = [ + "cuisine", + "gastronomie-française", + "recettes", + "plats-traditionnels", + "art-culinaire" + ] + + // MARK: Japanese Content + + static let japanesePost = """ + 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 + この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 + + 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 + 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 + + 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 + インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 + """ + + static let japaneseSiteTags = [ + "日本料理", + "レシピ", + "料理", + "伝統", + "和食" + ] + + // MARK: Mixed Language Content + + static let mixedLanguagePost = """ + 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. + """ +} diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift b/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift new file mode 100644 index 000000000000..5b357479ba28 --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift @@ -0,0 +1,118 @@ +import Foundation + +/// Helper utilities for formatting intelligence test output. +enum IntelligenceTestHelpers { + + // MARK: - Tag Suggestions + + static func printTagResults( + _ title: String, + tags: [String], + language: String, + expectations: [String] + ) { + printSection(title) + + print("Generated \(tags.count) tags:") + for (i, tag) in tags.enumerated() { + print(" \(i + 1). \(tag)") + } + + printExpectations(expectations) + } + + // MARK: - Summaries + + static func printSummaryResults( + _ title: String, + summary: String, + language: String, + expectations: [String] + ) { + printSection(title) + + let wordCount = summary.split(separator: " ").count + print("Summary (\(wordCount) words, \(summary.count) chars):") + print(summary) + + printExpectations(expectations) + } + + // MARK: - Excerpts + + static func printExcerptResults( + _ title: String, + excerpts: [String], + targetLength: String, + style: String, + expectations: [String] + ) { + printSection(title) + + print("Target: \(targetLength) words, Style: \(style)") + print("\nGenerated \(excerpts.count) variations:\n") + + for (i, excerpt) in excerpts.enumerated() { + let wordCount = excerpt.split(separator: " ").count + print("[\(i + 1)] (\(wordCount) words)") + print(excerpt) + print() + } + + printExpectations(expectations) + } + + // MARK: - Comparison Tables + + static func printComparisonTable( + _ title: String, + headers: [String], + rows: [[String]], + expectations: [String] + ) { + printSection(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 + for (i, header) in headers.enumerated() { + print(header.padding(toLength: widths[i], withPad: " ", startingAt: 0), terminator: i < headers.count - 1 ? " │ " : "\n") + } + + // Print separator + for (i, width) in widths.enumerated() { + print(String(repeating: "─", count: width), terminator: i < widths.count - 1 ? "─┼─" : "\n") + } + + // Print rows + for row in rows { + for (i, cell) in row.enumerated() where i < widths.count { + print(cell.padding(toLength: widths[i], withPad: " ", startingAt: 0), terminator: i < row.count - 1 ? " │ " : "\n") + } + } + + printExpectations(expectations) + } + + // MARK: - Utilities + + private static func printSection(_ title: String) { + print("\n" + String(repeating: "─", count: 80)) + print(title) + print(String(repeating: "─", count: 80)) + } + + private static func printExpectations(_ expectations: [String]) { + print("\nExpected:") + for expectation in expectations { + print(" • \(expectation)") + } + print() + } +} diff --git a/Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md b/Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md new file mode 100644 index 000000000000..aa802680cab7 --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md @@ -0,0 +1,193 @@ +# Intelligence Service Locale Tests + +This directory contains comprehensive tests for verifying locale support in the intelligence services, organized by feature. + +## Test Files + +### IntelligenceSuggestedTagsTests.swift +Tests for tag suggestions across different languages: +- 10 test cases covering Spanish, English, French, Japanese, and mixed languages +- Tests with matching/mismatching site tags and content languages +- Edge cases: empty site tags, very short/long content + +**Suite status**: Disabled (run tests individually) + +### IntelligencePostSummaryTests.swift +Tests for post and reader summaries: +- 7 test cases for full post summarization and reader-style summaries +- Covers Spanish, English, French, and Japanese content +- HTML content extraction and summarization + +**Suite status**: Disabled (run tests individually) + +### IntelligenceExcerptGenerationTests.swift +Tests for excerpt generation with various styles and lengths: +- 17 test cases covering all 5 styles and 3 lengths +- Multiple languages: Spanish, English, French, Japanese, German +- Comprehensive tests for all style/length combinations +- HTML content and edge cases + +**Suite status**: Disabled (run tests individually) + +## Running the Tests + +### Enabling Test Suites + +Each test file uses `@Suite(.disabled(...))` to disable the entire suite. To run tests: + +**Option 1: Run Individual Tests** +1. Open the test file in Xcode +2. Click the diamond icon next to any specific test method +3. Only that test will run + +**Option 2: Enable Entire Suite Temporarily** +1. Comment out the `.disabled(...)` trait on the `@Suite` attribute +2. Run all tests in the suite +3. Remember to re-disable before committing + +**Option 3: Run from Test Navigator** +1. Open Test Navigator (⌘6) +2. Find the specific test you want to run +3. Click the play button next to it + +### Testing with Different Locales + +To test how the AI responds to different system locales: + +1. **Edit Scheme**: + - Product → Scheme → Edit Scheme... + - Select "Test" in the left sidebar + - Go to the "Options" tab + - Under "Application Language", select the desired language + +2. **Run Tests**: + - Execute individual tests or the entire suite + - The AI will receive locale context based on your selection + +3. **Verify Results**: + - Check the console output for generated content + - Verify the language, tone, and format match expectations + +## Test Coverage + +### Language Combinations +- ✅ Spanish content → Spanish output +- ✅ English content → English output +- ✅ French content → French output +- ✅ Japanese content → Japanese output +- ✅ German content → German output +- ✅ Mixed language content +- ✅ Cross-language scenarios (Spanish content with English tags, etc.) + +### Content Types +- Recipe posts (Spanish, Japanese) +- Tech articles (Spanish, English, German) +- Travel posts (Spanish) +- Academic posts (English) +- Story posts (English) +- Culture posts (French) +- Business posts (French, Japanese) + +### Excerpt Styles (All tested) +- Engaging +- Conversational +- Witty +- Formal +- Professional + +### Excerpt Lengths (All tested) +- Short (20-40 words) +- Medium (50-70 words) +- Long (120-180 words) + +## Expected Behavior + +### Tag Suggestions +- Should match the language of site tags when available +- Should match the formatting pattern (lowercase-hyphenated, etc.) +- Should exclude existing post tags +- Should be relevant to content + +### Post Summaries +- Should be in the same language as the content +- Should capture main points concisely +- Should stream results progressively + +### Excerpts +- Should match specified length (word count) +- Should match specified style (tone) +- Should be in the same language as content +- Should follow WordPress excerpt best practices +- Should work as standalone content + +## Error Handling + +If tests are run on iOS < 26, they will throw: +``` +TestError.unsupportedIOSVersion: "Feature requires iOS 26 or later. +Current iOS version does not support these features." +``` + +This is expected behavior - the intelligence services require iOS 26+. + +## File Organization + +Tests are organized by **feature** rather than by language or scenario: + +``` +IntelligenceSuggestedTagsTests.swift → All tag suggestion scenarios +IntelligencePostSummaryTests.swift → All summarization scenarios +IntelligenceExcerptGenerationTests.swift → All excerpt generation scenarios +``` + +Each file: +- Uses `@Suite(.disabled(...))` to disable the entire suite +- Has focused test names without redundant prefixes +- Contains only the test data needed for that feature +- Includes dedicated helper methods with runtime availability checks + +## Debugging + +If tests fail or produce unexpected results: + +1. **Check console output** for the full AI response +2. **Verify locale** is set correctly in the scheme +3. **Review test data** to ensure it's appropriate +4. **Check rate limiting** if running many tests quickly +5. **Verify iOS 26+** is available (required for FoundationModels) +6. **Inspect errors** - runtime availability checks provide clear error messages + +## Adding New Tests + +To add new test scenarios: + +1. Choose the appropriate test file based on feature +2. Add test data to the private `TestData` or `ExcerptTestData` enum +3. Create a new test method with a descriptive name +4. Use the existing helper methods for service calls +5. Include expected behavior in print statements +6. Add appropriate assertions using `#expect()` + +Example: +```swift +@Test("German content with French site tags") +func germanContentFrenchTags() async throws { + let tags = try await suggestTags( + post: TestData.germanPost, + siteTags: TestData.frenchSiteTags, + postTags: [] + ) + + print("Generated tags:") + print(tags.joined(separator: ", ")) + print("\nExpected: Tags in French matching site tag format") +} +``` + +## Notes + +- All test suites are disabled by default +- Runtime availability checks ensure proper error handling on iOS < 26 +- Test data is localized and realistic for accurate testing +- Helper methods handle all platform-specific API calls +- The system locale affects AI output even when content is in a different language From 951d8e9d9620f3e0a6a6f698019feb288520f3e0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 3 Dec 2025 16:41:17 -0500 Subject: [PATCH 03/13] Refactoring --- .../Intelligence/IntelligenceService.swift | 194 +++--------- .../Intelligence/LanguageModelHelper.swift | 136 +-------- .../UseCases/ExcerptGeneration.swift | 164 ++++++++++ .../Intelligence/UseCases/PostSummary.swift | 37 +++ .../UseCases/SupportTicketSummary.swift | 46 +++ .../Intelligence/UseCases/TagSuggestion.swift | 85 ++++++ .../IntelligenceExcerptGenerationTests.swift | 286 ++++++++---------- .../IntelligencePostSummaryTests.swift | 114 +++---- .../IntelligenceSuggestedTagsTests.swift | 129 ++++---- .../IntelligenceTestHelpers.swift | 33 +- 10 files changed, 618 insertions(+), 606 deletions(-) create mode 100644 Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift create mode 100644 Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift create mode 100644 Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift create mode 100644 Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift index 9a2a3383aaed..55ffd0b55ca3 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift @@ -1,171 +1,73 @@ import Foundation import FoundationModels -@available(iOS 26, *) +/// Service for AI-powered content generation and analysis features. +/// +/// This service provides tag suggestions, post summaries, excerpt generation, +/// and other intelligence features using Foundation Models (iOS 26+). public actor IntelligenceService { + /// Maximum context size for language model sessions (in tokens). + /// /// A single token corresponds to three or four characters in languages like /// English, Spanish, or German, and one token per character in languages like /// Japanese, Chinese, or Korean. In a single session, the sum of all tokens /// in the instructions, all prompts, and all outputs count toward the context window size. /// /// https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models#Consider-context-size-limits-per-session - static let contextSizeLimit = 4096 + public static let contextSizeLimit = 4096 + /// Checks if intelligence features are supported on the current device. public nonisolated static var isSupported: Bool { - LanguageModelHelper.isSupported + guard #available(iOS 26, *) else { return false } + switch SystemLanguageModel.default.availability { + case .available: + return true + case .unavailable(let reason): + switch reason { + case .appleIntelligenceNotEnabled, .modelNotReady: + return true + case .deviceNotEligible: + return false + @unknown default: + return false + } + } } public init() {} + // MARK: - Public API + /// Suggests tags for a WordPress post. - /// - /// - 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. + @available(iOS 26, *) 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 = """ - \(makeLocaleInstructions())You are helping a WordPress user add tags to a post or a page. - - **Parameters** - - POST_CONTENT: contents of the post (HTML or plain text) - - SITE_TAGS: case-sensitive comma-separated list of the existing tags used elsewhere on the site (not always relevant to the post) - - EXISTING_POST_TAGS: tags already added to the post - - **Steps** - - 1. Identify the specific formatting pattern used (e.g., lowercase with underscores, capitalized words with spaces, etc) - - 2. Identify the language used in SITE_TAGS and POST_CONTENT - - 3. Generate a list of ten most relevant suggested tags based on POST_CONTENT and SITE_TAGS relevant to the content. - - **Requirements** - - You MUST generate tags in the same language as SITE_TAGS and POST_CONTENT - - Tags MUST match the formatting pattern and language of existing tags - - Do not include any tags from EXISTING_POST_TAGS - - If there are no relevant suggestions, returns an empty list - - Do not produce any output other than the final list of tag - """ - - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: instructions - ) - - let prompt = """ - Suggest up to ten tags for a post. - - POST_CONTENT: ''' - \(post) - ''' - - SITE_TAGS: '\(siteTags.joined(separator: ", "))' - - EXISTING_POST_TAGS: '\(postTags.joined(separator: ", "))' - """ - - let response = try await session.respond( - to: prompt, - generating: 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) } + try await TagSuggestion.execute(post: post, siteTags: siteTags, postTags: postTags) } /// 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. + @available(iOS 26, *) public func summarizePost(content: String) -> LanguageModelSession.ResponseStream { - let content = extractRelevantText(from: content, ratio: 0.8) - - let instructions = """ - \(makeLocaleInstructions())You are helping a WordPress user understand the content of a post. - Generate a concise summary that captures the main points and key information. - The summary should be clear, informative, and written in a neutral tone. - You MUST generate the summary in the same language as the post content. - - Do not include anything other than the summary in the response. - """ - - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: instructions - ) - - let prompt = """ - Summarize the following post: - - \(content) - """ - - return session.streamResponse(to: prompt) + PostSummary.execute(content: content) } + /// Summarizes a support ticket to a short title. + @available(iOS 26, *) public func summarizeSupportTicket(content: String) async throws -> String { - let instructions = """ - \(makeLocaleInstructions())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: SuggestedConversationTitle.self, - options: GenerationOptions(temperature: 1.0) - ).content.title + try await SupportTicketSummary.execute(content: content) } + /// Extracts relevant text from post content (removes HTML, limits size). public nonisolated func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { - let extract = try? IntelligenceUtilities.extractRelevantText(from: post) - let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio - return String((extract ?? post).prefix(Int(postSizeLimit))) + Self.extractRelevantText(from: post, ratio: ratio) } + // MARK: - Shared Utilities + /// Generates locale-specific instructions for the language model. /// /// Following Apple's recommended approach for multilingual support: /// https://developer.apple.com/documentation/foundationmodels/support-languages-and-locales-with-foundation-models - private nonisolated func makeLocaleInstructions(for locale: Locale = .current) -> String { + public nonisolated static func makeLocaleInstructions(for locale: Locale = .current) -> String { // Skip the locale phrase for U.S. English (Apple's recommendation) if Locale.Language(identifier: "en_US").isEquivalent(to: locale.language) { return "" @@ -173,25 +75,11 @@ public actor IntelligenceService { // Use the exact phrase format recommended by Apple to reduce hallucinations return "The person's locale is \(locale.identifier).\n" } -} -private extension Array where Element: Hashable { - func deduplicated() -> [Element] { - var seen = Set() - return filter { seen.insert($0).inserted } + /// Extracts relevant text from post content, removing HTML and limiting size. + public nonisolated static func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { + let extract = try? IntelligenceUtilities.extractRelevantText(from: post) + let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio + return String((extract ?? post).prefix(Int(postSizeLimit))) } } - -@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 index 663a2cb3c080..27148c6759c9 100644 --- a/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift +++ b/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift @@ -1,46 +1,23 @@ import Foundation import FoundationModels +// MARK: - Backward Compatibility Type Aliases +// These will be removed once all usages are migrated to IntelligenceService nested types + +@available(iOS 26, *) +public typealias GenerationStyle = IntelligenceService.ExcerptGeneration.Style + +@available(iOS 26, *) +public typealias GeneratedContentLength = IntelligenceService.ExcerptGeneration.Length + +@available(iOS 26, *) 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 - } - } + IntelligenceService.isSupported } 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. - """ + IntelligenceService.ExcerptGeneration.instructions } public static func makeGenerateExcerptPrompt( @@ -48,95 +25,10 @@ public enum LanguageModelHelper { 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) - """ + IntelligenceService.ExcerptGeneration.makePrompt(content: content, length: length, style: style) } 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" - } + IntelligenceService.ExcerptGeneration.loadMorePrompt } } diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift new file mode 100644 index 000000000000..3aa3a9d1ed80 --- /dev/null +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift @@ -0,0 +1,164 @@ +import Foundation +import FoundationModels + +@available(iOS 26, *) +extension IntelligenceService { + /// 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). + public enum ExcerptGeneration { + /// Writing style for generated excerpts. + public enum Style: String, CaseIterable, Sendable { + 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" + } + } + } + + /// Target length for generated excerpts. + public enum Length: Int, CaseIterable, Sendable { + 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 { + switch self { + case .short: "short" + case .medium: "medium" + case .long: "long" + } + } + + public var promptModifier: String { + "\(wordRange) words" + } + + private var wordRange: String { + switch self { + case .short: "20-40" + case .medium: "50-70" + case .long: "120-180" + } + } + } + + // MARK: Building Blocks (for UI with session continuity) + + /// Instructions for the language model session. + public static var instructions: String { + """ + \(IntelligenceService.makeLocaleInstructions())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. + """ + } + + /// Creates a prompt for generating excerpts. + public static func makePrompt(content: String, length: Length, style: Style) -> String { + """ + Generate three different excerpts for the given post and parameters + + GENERATED_CONTENT_LENGTH: \(length.promptModifier) + + GENERATION_STYLE: \(style.promptModifier) + + POST_CONTENT: ''' + \(content) + """ + } + + /// Prompt for generating additional excerpt options. + public static var loadMorePrompt: String { + "Generate additional three options" + } + + // MARK: Convenience Method (for simple one-shot usage) + + /// Generates excerpts in a single call (for tests and simple usage). + public static func generate( + content: String, + length: Length, + style: Style + ) async throws -> [String] { + let extractedContent = IntelligenceService.extractRelevantText(from: content) + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let response = try await session.respond( + to: makePrompt(content: extractedContent, length: length, style: style), + generating: Result.self, + options: GenerationOptions(temperature: 0.7) + ) + + return response.content.excerpts + } + + @Generable + public struct Result { + @Guide(description: "Three different excerpt variations") + public var excerpts: [String] + } + } +} diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift new file mode 100644 index 000000000000..901c3db8dacc --- /dev/null +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift @@ -0,0 +1,37 @@ +import Foundation +import FoundationModels + +@available(iOS 26, *) +extension IntelligenceService { + /// Post summarization for WordPress content. + /// + /// Generates concise summaries that capture the main points and key information + /// from WordPress post content in the same language as the source. + public enum PostSummary { + static func execute(content: String) -> LanguageModelSession.ResponseStream { + let content = IntelligenceService.extractRelevantText(from: content, ratio: 0.8) + + let instructions = """ + \(IntelligenceService.makeLocaleInstructions())You are helping a WordPress user understand the content of a post. + Generate a concise summary that captures the main points and key information. + The summary should be clear, informative, and written in a neutral tone. + You MUST generate the summary in the same language as the post content. + + Do not include anything other than the summary in the response. + """ + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let prompt = """ + Summarize the following post: + + \(content) + """ + + return session.streamResponse(to: prompt) + } + } +} diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift new file mode 100644 index 000000000000..31770571820f --- /dev/null +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift @@ -0,0 +1,46 @@ +import Foundation +import FoundationModels + +@available(iOS 26, *) +extension IntelligenceService { + /// Support ticket summarization. + /// + /// Generates short, concise titles (fewer than 10 words) for support + /// conversations based on the opening message. + public enum SupportTicketSummary { + static func execute(content: String) async throws -> String { + let instructions = """ + \(IntelligenceService.makeLocaleInstructions())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/WordPressShared/Intelligence/UseCases/TagSuggestion.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift new file mode 100644 index 000000000000..0a215895e4c6 --- /dev/null +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift @@ -0,0 +1,85 @@ +import Foundation +import FoundationModels + +@available(iOS 26, *) +extension IntelligenceService { + /// Tag suggestion for WordPress posts. + /// + /// Generates relevant tags based on post content and existing site tags, + /// matching the language and formatting pattern of existing tags. + public enum TagSuggestion { + static func execute(post: String, siteTags: [String], postTags: [String]) async throws -> [String] { + let startTime = CFAbsoluteTimeGetCurrent() + + // Limit siteTags and content size to respect context window + let siteTags = siteTags.prefix(50) + let post = IntelligenceService.extractRelevantText(from: post) + + try Task.checkCancellation() + + let instructions = """ + \(IntelligenceService.makeLocaleInstructions())You are helping a WordPress user add tags to a post or a page. + + **Parameters** + - POST_CONTENT: contents of the post (HTML or plain text) + - SITE_TAGS: case-sensitive comma-separated list of the existing tags used elsewhere on the site (not always relevant to the post) + - EXISTING_POST_TAGS: tags already added to the post + + **Steps** + - 1. Identify the specific formatting pattern used (e.g., lowercase with underscores, capitalized words with spaces, etc) + - 2. Identify the language used in SITE_TAGS and POST_CONTENT + - 3. Generate a list of ten most relevant suggested tags based on POST_CONTENT and SITE_TAGS relevant to the content. + + **Requirements** + - You MUST generate tags in the same language as SITE_TAGS and POST_CONTENT + - Tags MUST match the formatting pattern and language of existing tags + - Do not include any tags from EXISTING_POST_TAGS + - If there are no relevant suggestions, returns an empty list + - Do not produce any output other than the final list of tag + """ + + let session = LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: instructions + ) + + let prompt = """ + Suggest up to ten tags for a post. + + POST_CONTENT: ''' + \(post) + ''' + + SITE_TAGS: '\(siteTags.joined(separator: ", "))' + + EXISTING_POST_TAGS: '\(postTags.joined(separator: ", "))' + """ + + let response = try await session.respond( + to: prompt, + generating: Result.self, + options: GenerationOptions(temperature: 0.2) + ) + + WPLogInfo("IntelligenceService.TagSuggestion executed in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") + + let existingPostTags = Set(postTags) + return response.content.tags + .deduplicated() + .filter { !existingPostTags.contains($0) } + } + + @Generable + struct Result { + @Guide(description: "Newly generated tags following the identified format") + var tags: [String] + } + } +} + +private extension Array where Element: Hashable { + func deduplicated() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift index f1cad786299b..391f80627db0 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift @@ -9,11 +9,44 @@ import FoundationModels /// /// The entire test suite is disabled by default and should be run manually to verify /// the generated excerpts meet quality standards. -@Suite //(.disabled("Manual tests - Run individually to test excerpt generation")) +/// +/// ## What to Verify +/// +/// When running these tests, verify that the generated excerpts: +/// +/// **Language and Content** +/// - Are in the same language as the source content +/// - Are relevant to the source content topic +/// - Properly handle HTML tags (extracted/ignored, not visible in output) +/// - Work with special characters (accents, symbols) +/// +/// **Length Requirements** +/// - Short: 20-40 words +/// - Medium: 50-70 words +/// - Long: 120-180 words +/// - Japanese: Appropriate character count (not word count) +/// +/// **Style Requirements** +/// - Engaging: Engaging and compelling tone +/// - Conversational: Friendly and conversational tone +/// - Witty: Witty, creative, and entertaining +/// - Formal: Formal and academic tone +/// - Professional: Professional and polished tone +/// - Each style should have a distinctly different tone +/// +/// **Excerpt Best Practices** +/// - Include the post's main value proposition +/// - Use active voice (avoid "is", "are", "was", "were" when possible) +/// - End with implicit promise of more information +/// - No ellipsis (...) at the end +/// - Focus on value, not just summary +/// - Work as standalone content (independent from introduction) +@Suite//(.disabled("Manual tests - Run individually to test excerpt generation")) struct IntelligenceExcerptGenerationTests { // MARK: - Spanish Content Excerpts + @available(iOS 26, *) @Test("Short engaging Spanish excerpt") func spanishShortEngaging() async throws { let excerpts = try await generateExcerpts( @@ -23,25 +56,14 @@ struct IntelligenceExcerptGenerationTests { ) IntelligenceTestHelpers.printExcerptResults( - title: "Spanish Excerpt - Short Engaging", - input: .init( - contentLanguage: "Spanish", - targetLength: "20-40 words", - style: "Engaging", - contentPreview: IntelligenceTestHelpers.truncate(ExcerptTestData.spanishRecipePost) - ), - output: excerpts, - expectations: [ - "All excerpts are in Spanish", - "Each excerpt is 20-40 words long", - "Tone is engaging and compelling", - "Focus on value, not just summary", - "No ellipsis (...) at the end", - "Works as standalone content" - ] + "Spanish Excerpt - Short Engaging", + excerpts: excerpts, + targetLength: "20-40 words", + style: "Engaging" ) } + @available(iOS 26, *) @Test("Medium professional Spanish excerpt") func spanishMediumProfessional() async throws { let excerpts = try await generateExcerpts( @@ -50,15 +72,15 @@ struct IntelligenceExcerptGenerationTests { style: .professional ) - print("Medium Professional Spanish Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 50-70 words, professional tone, in Spanish") + IntelligenceTestHelpers.printExcerptResults( + "Spanish Excerpt - Medium Professional", + excerpts: excerpts, + targetLength: "50-70 words", + style: "Professional" + ) } + @available(iOS 26, *) @Test("Long conversational Spanish excerpt") func spanishLongConversational() async throws { let excerpts = try await generateExcerpts( @@ -67,17 +89,17 @@ struct IntelligenceExcerptGenerationTests { style: .conversational ) - print("Long Conversational Spanish Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 120-180 words, conversational tone, in Spanish") + IntelligenceTestHelpers.printExcerptResults( + "Spanish Excerpt - Long Conversational", + excerpts: excerpts, + targetLength: "120-180 words", + style: "Conversational" + ) } // MARK: - English Content Excerpts + @available(iOS 26, *) @Test("Short witty English excerpt") func englishShortWitty() async throws { let excerpts = try await generateExcerpts( @@ -86,15 +108,15 @@ struct IntelligenceExcerptGenerationTests { style: .witty ) - print("Short Witty English Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 20-40 words, witty tone, in English") + IntelligenceTestHelpers.printExcerptResults( + "English Excerpt - Short Witty", + excerpts: excerpts, + targetLength: "20-40 words", + style: "Witty" + ) } + @available(iOS 26, *) @Test("Medium formal English excerpt") func englishMediumFormal() async throws { let excerpts = try await generateExcerpts( @@ -103,15 +125,15 @@ struct IntelligenceExcerptGenerationTests { style: .formal ) - print("Medium Formal English Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 50-70 words, formal tone, in English") + IntelligenceTestHelpers.printExcerptResults( + "English Excerpt - Medium Formal", + excerpts: excerpts, + targetLength: "50-70 words", + style: "Formal" + ) } + @available(iOS 26, *) @Test("Long engaging English excerpt") func englishLongEngaging() async throws { let excerpts = try await generateExcerpts( @@ -120,17 +142,17 @@ struct IntelligenceExcerptGenerationTests { style: .engaging ) - print("Long Engaging English Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 120-180 words, engaging tone, in English") + IntelligenceTestHelpers.printExcerptResults( + "English Excerpt - Long Engaging", + excerpts: excerpts, + targetLength: "120-180 words", + style: "Engaging" + ) } // MARK: - French Content Excerpts + @available(iOS 26, *) @Test("Medium engaging French excerpt") func frenchMediumEngaging() async throws { let excerpts = try await generateExcerpts( @@ -139,15 +161,15 @@ struct IntelligenceExcerptGenerationTests { style: .engaging ) - print("Medium Engaging French Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 50-70 words, engaging tone, in French") + IntelligenceTestHelpers.printExcerptResults( + "French Excerpt - Medium Engaging", + excerpts: excerpts, + targetLength: "50-70 words", + style: "Engaging" + ) } + @available(iOS 26, *) @Test("Short professional French excerpt") func frenchShortProfessional() async throws { let excerpts = try await generateExcerpts( @@ -156,17 +178,17 @@ struct IntelligenceExcerptGenerationTests { style: .professional ) - print("Short Professional French Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 20-40 words, professional tone, in French") + IntelligenceTestHelpers.printExcerptResults( + "French Excerpt - Short Professional", + excerpts: excerpts, + targetLength: "20-40 words", + style: "Professional" + ) } // MARK: - Japanese Content Excerpts + @available(iOS 26, *) @Test("Medium conversational Japanese excerpt") func japaneseMediumConversational() async throws { let excerpts = try await generateExcerpts( @@ -175,15 +197,15 @@ struct IntelligenceExcerptGenerationTests { style: .conversational ) - print("Medium Conversational Japanese Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Character count: \(excerpt.count)") - } - - print("\nExpected: Appropriate length for Japanese, conversational tone") + IntelligenceTestHelpers.printExcerptResults( + "Japanese Excerpt - Medium Conversational", + excerpts: excerpts, + targetLength: "Appropriate for Japanese", + style: "Conversational" + ) } + @available(iOS 26, *) @Test("Short formal Japanese excerpt") func japaneseShortFormal() async throws { let excerpts = try await generateExcerpts( @@ -192,17 +214,17 @@ struct IntelligenceExcerptGenerationTests { style: .formal ) - print("Short Formal Japanese Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Character count: \(excerpt.count)") - } - - print("\nExpected: Appropriate length for Japanese, formal tone") + IntelligenceTestHelpers.printExcerptResults( + "Japanese Excerpt - Short Formal", + excerpts: excerpts, + targetLength: "Appropriate for Japanese", + style: "Formal" + ) } // MARK: - German Content Excerpts + @available(iOS 26, *) @Test("Medium professional German excerpt") func germanMediumProfessional() async throws { let excerpts = try await generateExcerpts( @@ -211,23 +233,23 @@ struct IntelligenceExcerptGenerationTests { style: .professional ) - print("Medium Professional German Excerpts:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - print("Word count: \(excerpt.split(separator: " ").count)") - } - - print("\nExpected: 50-70 words, professional tone, in German") + IntelligenceTestHelpers.printExcerptResults( + "German Excerpt - Medium Professional", + excerpts: excerpts, + targetLength: "50-70 words", + style: "Professional" + ) } // MARK: - Comprehensive Tests + @available(iOS 26, *) @Test("All styles for Spanish content") func spanishAllStyles() async throws { let content = ExcerptTestData.spanishRecipePost var rows: [[String]] = [] - for style in GenerationStyle.allCases { + for style in IntelligenceService.ExcerptGeneration.Style.allCases { let excerpts = try await generateExcerpts( content: content, length: .medium, @@ -237,7 +259,7 @@ struct IntelligenceExcerptGenerationTests { // Use first excerpt for comparison let excerpt = excerpts.first ?? "" let wordCount = excerpt.split(separator: " ").count - let preview = IntelligenceTestHelpers.truncate(excerpt, length: 60) + let preview = String(excerpt.prefix(60)) rows.append([ style.displayName, @@ -247,24 +269,19 @@ struct IntelligenceExcerptGenerationTests { } IntelligenceTestHelpers.printComparisonTable( - title: "Spanish Excerpts - All Styles Comparison (Medium Length)", + "Spanish Excerpts - All Styles Comparison (Medium Length)", headers: ["Style", "Length", "Preview"], - rows: rows, - expectations: [ - "Each style has distinct tone", - "All excerpts are 50-70 words", - "All excerpts are in Spanish", - "Content is relevant to Spanish tortilla recipe" - ] + rows: rows ) } + @available(iOS 26, *) @Test("All lengths for English content") func englishAllLengths() async throws { let content = ExcerptTestData.englishTechPost var rows: [[String]] = [] - for length in GeneratedContentLength.allCases { + for length in IntelligenceService.ExcerptGeneration.Length.allCases { let excerpts = try await generateExcerpts( content: content, length: length, @@ -274,7 +291,7 @@ struct IntelligenceExcerptGenerationTests { // Use first excerpt for comparison let excerpt = excerpts.first ?? "" let wordCount = excerpt.split(separator: " ").count - let preview = IntelligenceTestHelpers.truncate(excerpt, length: 60) + let preview = String(excerpt.prefix(60)) let expectedRange: String switch length { @@ -292,20 +309,15 @@ struct IntelligenceExcerptGenerationTests { } IntelligenceTestHelpers.printComparisonTable( - title: "English Excerpts - All Lengths Comparison (Engaging Style)", + "English Excerpts - All Lengths Comparison (Engaging Style)", headers: ["Length", "Expected", "Actual", "Preview"], - rows: rows, - expectations: [ - "Each length matches expected word count range", - "All excerpts are in English", - "Tone is engaging throughout", - "Content is relevant to quantum computing" - ] + rows: rows ) } // MARK: - HTML Content Tests + @available(iOS 26, *) @Test("Spanish HTML content") func spanishHTMLContent() async throws { let excerpts = try await generateExcerpts( @@ -324,6 +336,7 @@ struct IntelligenceExcerptGenerationTests { // MARK: - Edge Cases + @available(iOS 26, *) @Test("Very short content") func veryShortContent() async throws { let shortContent = "La inteligencia artificial está transformando nuestro mundo." @@ -342,6 +355,7 @@ struct IntelligenceExcerptGenerationTests { print("\nExpected: Reasonable excerpts even with limited source content") } + @available(iOS 26, *) @Test("Content with special characters") func specialCharacters() async throws { let content = """ @@ -366,62 +380,20 @@ struct IntelligenceExcerptGenerationTests { // MARK: - Helper Methods + @available(iOS 26, *) private func generateExcerpts( content: String, - length: GeneratedContentLength, - style: GenerationStyle + length: IntelligenceService.ExcerptGeneration.Length, + style: IntelligenceService.ExcerptGeneration.Style ) async throws -> [String] { - if #available(iOS 26, *) { - let service = IntelligenceService() - let extractedContent = service.extractRelevantText(from: content) - - let instructions = LanguageModelHelper.generateExcerptInstructions - let prompt = LanguageModelHelper.makeGenerateExcerptPrompt( - content: extractedContent, - length: length, - style: style - ) - - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: instructions - ) - - let response = try await session.respond( - to: prompt, - generating: GeneratedExcerpts.self, - options: GenerationOptions(temperature: 0.7) - ) - - return response.content.excerpts - } else { - throw TestError.unsupportedIOSVersion - } - } -} - -// MARK: - Test Errors - -private enum TestError: Error, CustomStringConvertible { - case unsupportedIOSVersion - - var description: String { - switch self { - case .unsupportedIOSVersion: - return "Excerpt generation requires iOS 26 or later. Current iOS version does not support these features." - } + try await IntelligenceService.ExcerptGeneration.generate( + content: content, + length: length, + style: style + ) } } -// MARK: - Supporting Types - -@available(iOS 26, *) -@Generable -private struct GeneratedExcerpts { - @Guide(description: "Three different excerpt variations") - var excerpts: [String] -} - // MARK: - Test Data private enum ExcerptTestData { diff --git a/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift index 67dab9bf6fd2..dc61af605726 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift @@ -7,6 +7,25 @@ import Testing /// languages. The entire test suite is disabled by default and should be run manually /// to verify the generated summaries match expectations. /// +/// ## What to Verify +/// +/// When running these tests, verify that the generated summaries: +/// +/// **Language and Content** +/// - Are in the same language as the source content +/// - Capture the main points of the article +/// - Are concise and informative (typically 2-4 sentences) +/// - Use a neutral, professional tone +/// +/// **For Reader Content** +/// - Are suitable for Reader context (news articles, blog posts) +/// - Provide clear overview of the article's main topic +/// - Maintain professional and informative tone +/// +/// **HTML Content** +/// - Properly handle/ignore HTML tags +/// - Extract content correctly without HTML artifacts +/// /// To test different system locales: /// 1. Change the scheme's Application Language in Xcode /// 2. Run the test suite manually @@ -14,6 +33,7 @@ import Testing @Suite(.disabled("Manual tests - Run individually to test post summaries")) struct IntelligencePostSummaryTests { + @available(iOS 26, *) @Test("Summarize Spanish post") func spanishContent() async throws { let summary = try await summarizePost(content: TestData.spanishPost) @@ -21,18 +41,13 @@ struct IntelligencePostSummaryTests { IntelligenceTestHelpers.printSummaryResults( "Spanish Post Summary", summary: summary, - language: "Spanish", - expectations: [ - "Summary is in Spanish", - "Captures main points about paella", - "Concise and informative", - "Neutral tone" - ] + language: "Spanish" ) #expect(!summary.isEmpty, "Summary should not be empty") } + @available(iOS 26, *) @Test("Summarize English post") func englishContent() async throws { let summary = try await summarizePost(content: TestData.englishPost) @@ -40,18 +55,13 @@ struct IntelligencePostSummaryTests { IntelligenceTestHelpers.printSummaryResults( "English Post Summary", summary: summary, - language: "English", - expectations: [ - "Summary is in English", - "Captures main points about sourdough bread", - "Concise and informative", - "Neutral tone" - ] + language: "English" ) #expect(!summary.isEmpty, "Summary should not be empty") } + @available(iOS 26, *) @Test("Summarize French post") func frenchContent() async throws { let summary = try await summarizePost(content: TestData.frenchPost) @@ -59,18 +69,13 @@ struct IntelligencePostSummaryTests { IntelligenceTestHelpers.printSummaryResults( "French Post Summary", summary: summary, - language: "French", - expectations: [ - "Summary is in French", - "Captures main points about French cuisine", - "Concise and informative", - "Neutral tone" - ] + language: "French" ) #expect(!summary.isEmpty, "Summary should not be empty") } + @available(iOS 26, *) @Test("Summarize Japanese post") func japaneseContent() async throws { let summary = try await summarizePost(content: TestData.japanesePost) @@ -78,18 +83,13 @@ struct IntelligencePostSummaryTests { IntelligenceTestHelpers.printSummaryResults( "Japanese Post Summary", summary: summary, - language: "Japanese", - expectations: [ - "Summary is in Japanese", - "Captures main points about dashi", - "Concise and informative", - "Neutral tone" - ] + language: "Japanese" ) #expect(!summary.isEmpty, "Summary should not be empty") } + @available(iOS 26, *) @Test("Summarize HTML-heavy Spanish post") func spanishHTMLContent() async throws { let summary = try await summarizePost(content: TestData.spanishPostWithHTML) @@ -97,18 +97,13 @@ struct IntelligencePostSummaryTests { IntelligenceTestHelpers.printSummaryResults( "Spanish HTML Content Summary", summary: summary, - language: "Spanish", - expectations: [ - "Summary is in Spanish", - "HTML tags properly handled/ignored", - "Content extracted correctly", - "Relevant to paella recipe" - ] + language: "Spanish" ) #expect(!summary.isEmpty, "Summary should not be empty") } + @available(iOS 26, *) @Test("Reader summary for Spanish article") func readerSummarySpanishContent() async throws { let summary = try await summarizePost(content: TestData.spanishReaderArticle) @@ -116,18 +111,13 @@ struct IntelligencePostSummaryTests { IntelligenceTestHelpers.printSummaryResults( "Spanish Reader Article Summary", summary: summary, - language: "Spanish", - expectations: [ - "Summary is in Spanish", - "Concise and suitable for Reader", - "Captures climate change impact on Mediterranean", - "Professional and informative tone" - ] + language: "Spanish" ) #expect(!summary.isEmpty, "Summary should not be empty") } + @available(iOS 26, *) @Test("Reader summary for English article") func readerSummaryEnglishContent() async throws { let summary = try await summarizePost(content: TestData.englishReaderArticle) @@ -135,13 +125,7 @@ struct IntelligencePostSummaryTests { IntelligenceTestHelpers.printSummaryResults( "English Reader Article Summary", summary: summary, - language: "English", - expectations: [ - "Summary is in English", - "Concise and suitable for Reader", - "Captures quantum computing advances", - "Professional and informative tone" - ] + language: "English" ) #expect(!summary.isEmpty, "Summary should not be empty") @@ -149,33 +133,17 @@ struct IntelligencePostSummaryTests { // MARK: - Helper Methods + @available(iOS 26, *) private func summarizePost(content: String) async throws -> String { - if #available(iOS 26, *) { - let service = IntelligenceService() - var summary = "" - - for try await chunk in await service.summarizePost(content: content) { - summary = chunk.content - print(chunk.content, terminator: "") - } - - return summary - } else { - throw TestError.unsupportedIOSVersion - } - } -} - -// MARK: - Test Errors + let service = IntelligenceService() + var summary = "" -private enum TestError: Error, CustomStringConvertible { - case unsupportedIOSVersion - - var description: String { - switch self { - case .unsupportedIOSVersion: - return "Post summarization requires iOS 26 or later. Current iOS version does not support these features." + for try await chunk in await service.summarizePost(content: content) { + summary = chunk.content + print(chunk.content, terminator: "") } + + return summary } } diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift index d62d04f158c4..e19ca2aacaa4 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift @@ -7,6 +7,32 @@ import Testing /// languages and locale settings. The entire test suite is disabled by default /// and should be run manually to verify the generated tags match expectations. /// +/// ## What to Verify +/// +/// When running these tests, verify that the generated tags: +/// +/// **Language Matching** +/// - Tags should match the language of the site tags (if provided) +/// - If no site tags: tags should match the content language +/// - Mixed language content: tags match dominant language +/// +/// **Format and Style** +/// - Use lowercase-hyphenated format (e.g., "spanish-cuisine") +/// - Japanese tags may use native characters without hyphens +/// - Consistent formatting across all generated tags +/// +/// **Relevance and Quality** +/// - Are relevant to the post content +/// - Don't duplicate existing post tags +/// - Are appropriate for the topic (cooking, technology, etc.) +/// - Make sense as blog post tags +/// +/// **Edge Cases** +/// - Work with empty site tags +/// - Work with very short content +/// - Work with very long content (truncated appropriately) +/// - Properly exclude existing post tags +/// /// To test different system locales: /// 1. Change the scheme's Application Language in Xcode /// 2. Run the test suite manually @@ -14,6 +40,7 @@ import Testing @Suite(.disabled("Manual tests - Run individually to test tag suggestions")) struct IntelligenceSuggestedTagsTests { + @available(iOS 26, *) @Test("Spanish content with Spanish site tags") func spanishContentSpanishTags() async throws { let tags = try await suggestTags( @@ -25,15 +52,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "Spanish Content → Spanish Site Tags", tags: tags, - language: "Spanish", - expectations: [ - "All tags in Spanish", - "Lowercase-hyphenated format", - "Relevant to paella/cooking" - ] + language: "Spanish" ) } + @available(iOS 26, *) @Test("Spanish content with English site tags") func spanishContentEnglishTags() async throws { let tags = try await suggestTags( @@ -45,15 +68,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "Spanish Content → English Site Tags", tags: tags, - language: "English", - expectations: [ - "All tags in English (matching site tags)", - "Lowercase-hyphenated format", - "Relevant to content" - ] + language: "English" ) } + @available(iOS 26, *) @Test("English content with Spanish site tags") func englishContentSpanishTags() async throws { let tags = try await suggestTags( @@ -65,15 +84,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "English Content → Spanish Site Tags", tags: tags, - language: "Spanish", - expectations: [ - "All tags in Spanish (matching site tags)", - "Lowercase-hyphenated format", - "Relevant to sourdough/baking" - ] + language: "Spanish" ) } + @available(iOS 26, *) @Test("Mixed language content") func mixedLanguageContent() async throws { let tags = try await suggestTags( @@ -85,14 +100,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "Mixed Language Content", tags: tags, - language: "Dominant language", - expectations: [ - "Tags match dominant language in content", - "Relevant to Mediterranean diet" - ] + language: "Dominant language" ) } + @available(iOS 26, *) @Test("French content") func frenchContent() async throws { let tags = try await suggestTags( @@ -104,15 +116,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "French Content → French Site Tags", tags: tags, - language: "French", - expectations: [ - "All tags in French", - "Lowercase-hyphenated format", - "Relevant to French cuisine" - ] + language: "French" ) } + @available(iOS 26, *) @Test("Japanese content") func japaneseContent() async throws { let tags = try await suggestTags( @@ -124,14 +132,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "Japanese Content → Japanese Site Tags", tags: tags, - language: "Japanese", - expectations: [ - "All tags in Japanese", - "Relevant to Japanese cuisine/dashi" - ] + language: "Japanese" ) } + @available(iOS 26, *) @Test("Existing post tags should be excluded") func excludeExistingTags() async throws { let existingTags = ["recetas", "cocina"] @@ -144,17 +149,13 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "Exclude Existing Tags: \(existingTags.joined(separator: ", "))", tags: tags, - language: "Spanish", - expectations: [ - "Does NOT include: \(existingTags.joined(separator: ", "))", - "All new tags in Spanish", - "Relevant to content" - ] + language: "Spanish" ) #expect(!tags.contains { existingTags.contains($0) }) } + @available(iOS 26, *) @Test("Empty site tags") func emptySiteTags() async throws { let tags = try await suggestTags( @@ -166,14 +167,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "No Site Tags Context", tags: tags, - language: "Spanish", - expectations: [ - "Tags in Spanish (content language)", - "Generated from content alone" - ] + language: "Spanish" ) } + @available(iOS 26, *) @Test("Very short content") func shortContent() async throws { let tags = try await suggestTags( @@ -185,14 +183,11 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "Very Short Content", tags: tags, - language: "Spanish", - expectations: [ - "Tags in Spanish", - "Relevant despite short content" - ] + language: "Spanish" ) } + @available(iOS 26, *) @Test("Very long content") func longContent() async throws { let longContent = String(repeating: TestData.spanishPost + "\n\n", count: 5) @@ -205,39 +200,19 @@ struct IntelligenceSuggestedTagsTests { IntelligenceTestHelpers.printTagResults( "Very Long Content (Truncated)", tags: tags, - language: "Spanish", - expectations: [ - "Tags in Spanish", - "Still relevant despite truncation" - ] + language: "Spanish" ) } // MARK: - Helper Methods + @available(iOS 26, *) private func suggestTags(post: String, siteTags: [String], postTags: [String]) async throws -> [String] { - if #available(iOS 26, *) { - return try await IntelligenceService().suggestTags( - post: post, - siteTags: siteTags, - postTags: postTags - ) - } else { - throw TestError.unsupportedIOSVersion - } - } -} - -// MARK: - Test Errors - -private enum TestError: Error, CustomStringConvertible { - case unsupportedIOSVersion - - var description: String { - switch self { - case .unsupportedIOSVersion: - return "Tag suggestion requires iOS 26 or later. Current iOS version does not support these features." - } + try await IntelligenceService().suggestTags( + post: post, + siteTags: siteTags, + postTags: postTags + ) } } diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift b/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift index 5b357479ba28..51c4b4f7f9dd 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift @@ -8,17 +8,16 @@ enum IntelligenceTestHelpers { static func printTagResults( _ title: String, tags: [String], - language: String, - expectations: [String] + language: String ) { printSection(title) + print("Language: \(language)") print("Generated \(tags.count) tags:") for (i, tag) in tags.enumerated() { print(" \(i + 1). \(tag)") } - - printExpectations(expectations) + print() } // MARK: - Summaries @@ -26,16 +25,15 @@ enum IntelligenceTestHelpers { static func printSummaryResults( _ title: String, summary: String, - language: String, - expectations: [String] + language: String ) { printSection(title) + print("Language: \(language)") let wordCount = summary.split(separator: " ").count print("Summary (\(wordCount) words, \(summary.count) chars):") print(summary) - - printExpectations(expectations) + print() } // MARK: - Excerpts @@ -44,8 +42,7 @@ enum IntelligenceTestHelpers { _ title: String, excerpts: [String], targetLength: String, - style: String, - expectations: [String] + style: String ) { printSection(title) @@ -58,8 +55,6 @@ enum IntelligenceTestHelpers { print(excerpt) print() } - - printExpectations(expectations) } // MARK: - Comparison Tables @@ -67,8 +62,7 @@ enum IntelligenceTestHelpers { static func printComparisonTable( _ title: String, headers: [String], - rows: [[String]], - expectations: [String] + rows: [[String]] ) { printSection(title) @@ -96,8 +90,7 @@ enum IntelligenceTestHelpers { print(cell.padding(toLength: widths[i], withPad: " ", startingAt: 0), terminator: i < row.count - 1 ? " │ " : "\n") } } - - printExpectations(expectations) + print() } // MARK: - Utilities @@ -107,12 +100,4 @@ enum IntelligenceTestHelpers { print(title) print(String(repeating: "─", count: 80)) } - - private static func printExpectations(_ expectations: [String]) { - print("\nExpected:") - for expectation in expectations { - print(" • \(expectation)") - } - print() - } } From a8c6991ff76e943113b087b0556daece51fdb4fd Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 3 Dec 2025 16:48:10 -0500 Subject: [PATCH 04/13] Remove locale instructions --- .../Intelligence/IntelligenceService.swift | 13 ------------- .../Intelligence/UseCases/ExcerptGeneration.swift | 4 +++- .../Intelligence/UseCases/PostSummary.swift | 2 +- .../UseCases/SupportTicketSummary.swift | 2 +- .../Intelligence/UseCases/TagSuggestion.swift | 2 +- 5 files changed, 6 insertions(+), 17 deletions(-) diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift index 55ffd0b55ca3..8c35133b1e6b 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift @@ -63,19 +63,6 @@ public actor IntelligenceService { // MARK: - Shared Utilities - /// Generates locale-specific instructions for the language model. - /// - /// Following Apple's recommended approach for multilingual support: - /// https://developer.apple.com/documentation/foundationmodels/support-languages-and-locales-with-foundation-models - public nonisolated static func makeLocaleInstructions(for locale: Locale = .current) -> String { - // Skip the locale phrase for U.S. English (Apple's recommendation) - if Locale.Language(identifier: "en_US").isEquivalent(to: locale.language) { - return "" - } - // Use the exact phrase format recommended by Apple to reduce hallucinations - return "The person's locale is \(locale.identifier).\n" - } - /// Extracts relevant text from post content, removing HTML and limiting size. public nonisolated static func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { let extract = try? IntelligenceUtilities.extractRelevantText(from: post) diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift index 3aa3a9d1ed80..a073e616d9a6 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift @@ -90,7 +90,9 @@ extension IntelligenceService { /// Instructions for the language model session. public static var instructions: String { """ - \(IntelligenceService.makeLocaleInstructions())Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. + Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. + + Generate excerpts in the same language as the POST_CONTENT. **Paramters** - POST_CONTENT: contents of the post (HTML or plain text) diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift index 901c3db8dacc..1f980e2e5e39 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift @@ -12,7 +12,7 @@ extension IntelligenceService { let content = IntelligenceService.extractRelevantText(from: content, ratio: 0.8) let instructions = """ - \(IntelligenceService.makeLocaleInstructions())You are helping a WordPress user understand the content of a post. + You are helping a WordPress user understand the content of a post. Generate a concise summary that captures the main points and key information. The summary should be clear, informative, and written in a neutral tone. You MUST generate the summary in the same language as the post content. diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift index 31770571820f..15e15fcf3405 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift @@ -10,7 +10,7 @@ extension IntelligenceService { public enum SupportTicketSummary { static func execute(content: String) async throws -> String { let instructions = """ - \(IntelligenceService.makeLocaleInstructions())You are helping a user by summarizing their support request down to a single sentence + 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. diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift index 0a215895e4c6..40ccd7bbb8cf 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift @@ -18,7 +18,7 @@ extension IntelligenceService { try Task.checkCancellation() let instructions = """ - \(IntelligenceService.makeLocaleInstructions())You are helping a WordPress user add tags to a post or a page. + 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) From aa6748569bf54978f5ce004335bf07d493366e07 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 3 Dec 2025 21:19:28 -0500 Subject: [PATCH 05/13] Refactor --- .../IntelligenceExcerptGenerationTests.swift | 421 +++--------------- .../IntelligencePostSummaryTests.swift | 176 ++------ .../IntelligenceSuggestedTagsTests.swift | 226 ++-------- .../IntelligenceTestData.swift | 319 +++++++++++++ .../IntelligenceTestHelpers.swift | 138 +++++- 5 files changed, 547 insertions(+), 733 deletions(-) create mode 100644 Modules/Tests/WordPressSharedTests/IntelligenceTestData.swift diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift index 391f80627db0..c3c986cf59b8 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift @@ -44,200 +44,22 @@ import FoundationModels @Suite//(.disabled("Manual tests - Run individually to test excerpt generation")) struct IntelligenceExcerptGenerationTests { - // MARK: - Spanish Content Excerpts + // MARK: - Parameterized Language & Style Tests @available(iOS 26, *) - @Test("Short engaging Spanish excerpt") - func spanishShortEngaging() async throws { + @Test(arguments: ExcerptTestCase.allCases) + func excerptGeneration(testCase: ExcerptTestCase) async throws { let excerpts = try await generateExcerpts( - content: ExcerptTestData.spanishRecipePost, - length: .short, - style: .engaging - ) - - IntelligenceTestHelpers.printExcerptResults( - "Spanish Excerpt - Short Engaging", - excerpts: excerpts, - targetLength: "20-40 words", - style: "Engaging" - ) - } - - @available(iOS 26, *) - @Test("Medium professional Spanish excerpt") - func spanishMediumProfessional() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.spanishTechPost, - length: .medium, - style: .professional - ) - - IntelligenceTestHelpers.printExcerptResults( - "Spanish Excerpt - Medium Professional", - excerpts: excerpts, - targetLength: "50-70 words", - style: "Professional" - ) - } - - @available(iOS 26, *) - @Test("Long conversational Spanish excerpt") - func spanishLongConversational() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.spanishTravelPost, - length: .long, - style: .conversational - ) - - IntelligenceTestHelpers.printExcerptResults( - "Spanish Excerpt - Long Conversational", - excerpts: excerpts, - targetLength: "120-180 words", - style: "Conversational" - ) - } - - // MARK: - English Content Excerpts - - @available(iOS 26, *) - @Test("Short witty English excerpt") - func englishShortWitty() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.englishTechPost, - length: .short, - style: .witty - ) - - IntelligenceTestHelpers.printExcerptResults( - "English Excerpt - Short Witty", - excerpts: excerpts, - targetLength: "20-40 words", - style: "Witty" - ) - } - - @available(iOS 26, *) - @Test("Medium formal English excerpt") - func englishMediumFormal() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.englishAcademicPost, - length: .medium, - style: .formal - ) - - IntelligenceTestHelpers.printExcerptResults( - "English Excerpt - Medium Formal", - excerpts: excerpts, - targetLength: "50-70 words", - style: "Formal" - ) - } - - @available(iOS 26, *) - @Test("Long engaging English excerpt") - func englishLongEngaging() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.englishStoryPost, - length: .long, - style: .engaging - ) - - IntelligenceTestHelpers.printExcerptResults( - "English Excerpt - Long Engaging", - excerpts: excerpts, - targetLength: "120-180 words", - style: "Engaging" - ) - } - - // MARK: - French Content Excerpts - - @available(iOS 26, *) - @Test("Medium engaging French excerpt") - func frenchMediumEngaging() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.frenchCulturePost, - length: .medium, - style: .engaging - ) - - IntelligenceTestHelpers.printExcerptResults( - "French Excerpt - Medium Engaging", - excerpts: excerpts, - targetLength: "50-70 words", - style: "Engaging" - ) - } - - @available(iOS 26, *) - @Test("Short professional French excerpt") - func frenchShortProfessional() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.frenchBusinessPost, - length: .short, - style: .professional - ) - - IntelligenceTestHelpers.printExcerptResults( - "French Excerpt - Short Professional", - excerpts: excerpts, - targetLength: "20-40 words", - style: "Professional" - ) - } - - // MARK: - Japanese Content Excerpts - - @available(iOS 26, *) - @Test("Medium conversational Japanese excerpt") - func japaneseMediumConversational() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.japaneseCookingPost, - length: .medium, - style: .conversational - ) - - IntelligenceTestHelpers.printExcerptResults( - "Japanese Excerpt - Medium Conversational", - excerpts: excerpts, - targetLength: "Appropriate for Japanese", - style: "Conversational" - ) - } - - @available(iOS 26, *) - @Test("Short formal Japanese excerpt") - func japaneseShortFormal() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.japaneseBusinessPost, - length: .short, - style: .formal - ) - - IntelligenceTestHelpers.printExcerptResults( - "Japanese Excerpt - Short Formal", - excerpts: excerpts, - targetLength: "Appropriate for Japanese", - style: "Formal" - ) - } - - // MARK: - German Content Excerpts - - @available(iOS 26, *) - @Test("Medium professional German excerpt") - func germanMediumProfessional() async throws { - let excerpts = try await generateExcerpts( - content: ExcerptTestData.germanTechPost, - length: .medium, - style: .professional + content: testCase.content, + length: testCase.length, + style: testCase.style ) IntelligenceTestHelpers.printExcerptResults( - "German Excerpt - Medium Professional", + testCase.title, excerpts: excerpts, - targetLength: "50-70 words", - style: "Professional" + targetLength: testCase.targetLengthDescription, + style: testCase.style.displayName ) } @@ -246,7 +68,7 @@ struct IntelligenceExcerptGenerationTests { @available(iOS 26, *) @Test("All styles for Spanish content") func spanishAllStyles() async throws { - let content = ExcerptTestData.spanishRecipePost + let content = IntelligenceTestData.spanishRecipePost var rows: [[String]] = [] for style in IntelligenceService.ExcerptGeneration.Style.allCases { @@ -278,7 +100,7 @@ struct IntelligenceExcerptGenerationTests { @available(iOS 26, *) @Test("All lengths for English content") func englishAllLengths() async throws { - let content = ExcerptTestData.englishTechPost + let content = IntelligenceTestData.englishTechPost var rows: [[String]] = [] for length in IntelligenceService.ExcerptGeneration.Length.allCases { @@ -321,7 +143,7 @@ struct IntelligenceExcerptGenerationTests { @Test("Spanish HTML content") func spanishHTMLContent() async throws { let excerpts = try await generateExcerpts( - content: ExcerptTestData.spanishHTMLPost, + content: IntelligenceTestData.spanishHTMLPost, length: .medium, style: .engaging ) @@ -394,181 +216,48 @@ struct IntelligenceExcerptGenerationTests { } } -// MARK: - Test Data +// MARK: - Test Cases + +private struct ExcerptTestCase: CustomTestStringConvertible { + let title: String + let content: String + let length: IntelligenceService.ExcerptGeneration.Length + let style: IntelligenceService.ExcerptGeneration.Style + let language: String + + var testDescription: String { title } + + var targetLengthDescription: String { + switch length { + case .short: "20-40 words" + case .medium: "50-70 words" + case .long: "120-180 words" + } + } -private enum ExcerptTestData { - static let spanishRecipePost = """ - La auténtica tortilla española es mucho más que simplemente huevos y patatas. Es un arte - culinario que requiere paciencia, técnica y los ingredientes adecuados. La clave está en - lograr el equilibrio perfecto: patatas tiernas pero no deshechas, huevos jugosos en el centro, - y esa capa exterior ligeramente dorada que aporta textura. - - El secreto mejor guardado de las abuelas españolas es la temperatura del aceite. Debe estar - lo suficientemente caliente para cocinar las patatas, pero no tanto como para dorarlas rápidamente. - Este proceso lento permite que las patatas se impregnen del aceite de oliva, creando esa textura - cremosa característica. - - Algunos puristas insisten en que solo debe llevar patatas, huevos, cebolla y sal. Otros - defienden versiones más innovadoras con chorizo, pimientos o incluso espinacas. Sin embargo, - la receta tradicional valenciana ha resistido el paso del tiempo por una razón: su simplicidad - permite que brillen los sabores fundamentales. - """ - - static let spanishTechPost = """ - La inteligencia artificial generativa ha revolucionado la forma en que interactuamos con la - tecnología. Modelos de lenguaje como GPT-4 y Claude han demostrado capacidades sorprendentes - en tareas que van desde la redacción creativa hasta el análisis de código complejo. - - Sin embargo, esta transformación digital plantea importantes cuestiones éticas. ¿Cómo garantizamos - que estos sistemas sean justos e imparciales? ¿Qué medidas debemos implementar para proteger - la privacidad de los datos? ¿Cómo equilibramos la automatización con la preservación del empleo? - - Los expertos coinciden en que necesitamos un marco regulatorio robusto que fomente la innovación - mientras protege los derechos fundamentales. La Unión Europea ha dado un paso importante con su - Ley de IA, estableciendo un precedente para otras regiones del mundo. - """ - - static let spanishTravelPost = """ - Sevilla en primavera es una experiencia sensorial incomparable. Las calles estrechas del barrio - de Santa Cruz se llenan del aroma de azahar, mientras el sonido de las guitarras flamencas - resuena desde los patios escondidos. Durante la Feria de Abril, la ciudad se transforma en un - espectáculo de color, música y tradición que deja sin aliento a los visitantes. - - Pasear por el Real Alcázar al atardecer es como retroceder en el tiempo. Los jardines moriscos, - con sus fuentes burbujeantes y naranjos centenarios, ofrecen un oasis de tranquilidad en medio - del bullicio urbano. La mezcla de arquitectura mudéjar, gótica y renacentista cuenta la rica - historia de una ciudad que ha sido encrucijada de culturas durante milenios. - - No se puede visitar Sevilla sin probar las tapas auténticas. Olvidaos de los lugares turísticos - y aventuraos en los bares de barrio donde los locales se congregan. Un montadito de pringá, unas - espinacas con garbanzos, o el clásico salmorejo cordobés acompañados de un fino bien frío son - la verdadera esencia de la cultura sevillana. - """ - - static let spanishHTMLPost = """ - -

Los Beneficios del Yoga para la Salud Mental

- - - -

El yoga no es solo una práctica física; es una disciplina holística que integra cuerpo, - mente y espíritu. Numerosos estudios científicos han demostrado sus efectos positivos en - la reducción del estrés, la ansiedad y la depresión.

- - - -
    -
  • Mejora la concentración y claridad mental
  • -
  • Reduce los niveles de cortisol (hormona del estrés)
  • -
  • Promueve un sueño más reparador
  • -
  • Aumenta la autoconciencia y autoestima
  • -
- - """ - - static let englishTechPost = """ - Quantum computing represents a paradigm shift in how we approach computational problems. Unlike - classical computers that use bits (0s and 1s), quantum computers leverage qubits that can exist - in superposition, simultaneously representing multiple states. - - This fundamental difference enables quantum computers to tackle problems that are intractable - for classical machines. Drug discovery, cryptography, optimization, and climate modeling are - just a few domains poised for revolutionary breakthroughs. - - However, significant challenges remain. Quantum systems are incredibly fragile, requiring - near-absolute-zero temperatures and isolation from environmental interference. Error correction - is another major hurdle, as quantum states are prone to decoherence. - """ - - static let englishAcademicPost = """ - The phenomenon of linguistic relativity, often referred to as the Sapir-Whorf hypothesis, - posits that the structure of a language influences its speakers' worldview and cognition. - While the strong version of this hypothesis has been largely discredited, contemporary research - suggests more nuanced relationships between language and thought. - - Recent studies in cognitive linguistics have demonstrated that language can indeed affect - perception and categorization, particularly in domains like color perception, spatial reasoning, - and temporal cognition. However, these effects are context-dependent and vary significantly - across different cognitive domains. - - Cross-linguistic research continues to provide valuable insights into the universal and - language-specific aspects of human cognition, challenging researchers to refine their - theoretical frameworks and methodological approaches. - """ - - static let englishStoryPost = """ - The old lighthouse keeper had seen many storms in his forty years tending the beacon, but - none quite like the tempest that rolled in that October evening. Dark clouds gathered on - the horizon like an invading army, their edges tinged with an unsettling green hue. - - As the first drops of rain pelted the lighthouse windows, Magnus checked the lamp one final - time. The beam cut through the gathering darkness, a lifeline for any vessels brave or foolish - enough to be out on such a night. He'd heard the coastguard warnings on the radio—winds - exceeding 90 miles per hour, waves reaching heights of 30 feet. - - Down in the keeper's quarters, Magnus brewed strong coffee and settled into his worn leather - chair. Outside, the wind howled like a wounded beast, but within these thick stone walls, - he felt safe. This lighthouse had withstood two centuries of nature's fury; it would stand - through one more night. - """ - - static let frenchCulturePost = """ - Le café français est bien plus qu'une simple boisson; c'est une institution culturelle qui - incarne l'art de vivre à la française. Depuis les cafés littéraires de Saint-Germain-des-Prés - fréquentés par Sartre et de Beauvoir jusqu'aux bistrots de quartier où les habitués se - retrouvent chaque matin, ces établissements sont le cœur battant de la vie sociale française. - - L'architecture des cafés parisiens, avec leurs terrasses caractéristiques et leurs décors - Art nouveau ou Art déco, raconte l'histoire de la ville. Chaque café a son caractère unique, - son ambiance particulière, et ses réguliers qui y viennent non seulement pour le café, - mais surtout pour la conversation, l'observation, et cette délicieuse pause dans le rythme - effréné de la vie moderne. - """ - - static let frenchBusinessPost = """ - La transformation numérique des entreprises françaises s'accélère, portée par la nécessité - de rester compétitives dans un marché mondialisé. Les PME, longtemps réticentes à adopter - de nouvelles technologies, reconnaissent désormais l'importance cruciale de l'innovation - digitale pour leur survie et leur croissance. - - Les investissements dans l'intelligence artificielle, le cloud computing, et la cybersécurité - augmentent exponentiellement. Le gouvernement français soutient cette transition avec des - programmes d'accompagnement et des incitations fiscales, conscient que la compétitivité - future du pays en dépend. - """ - - static let japaneseCookingPost = """ - 日本料理における包丁の技術は、単なる食材の切断以上の意味を持ちます。それは料理人の - 心と技が一体となる瞬間であり、何年もの修行を通じて磨かれる芸術形態です。 - - 寿司職人が魚を捌く際の手つきには、無駄な動きが一切ありません。一つ一つの動作が計算され、 - 食材の繊維を理解し、その特性を最大限に引き出すための最適な方法が選ばれています。 - この技術は「包丁さばき」と呼ばれ、日本料理の根幹を成すものです。 - - 家庭料理においても、適切な包丁の使い方を学ぶことは重要です。正しい持ち方、切り方を - マスターすることで、料理の効率が上がるだけでなく、食材の味わいも向上します。 - 包丁に対する敬意と理解が、美味しい料理への第一歩なのです。 - """ - - static let japaneseBusinessPost = """ - 日本企業のグローバル展開において、文化的な適応能力が成功の鍵となっています。 - 従来の日本的経営手法を維持しながら、現地の商習慣や消費者ニーズに柔軟に対応する - バランス感覚が求められています。 - - 特に人材マネジメントの分野では、年功序列や終身雇用といった伝統的なシステムと、 - 成果主義やダイバーシティといった現代的な価値観の融合が課題となっています。 - 多くの企業が試行錯誤を重ねながら、新しい時代に適した経営モデルを模索しています。 - """ - - static let germanTechPost = """ - 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. - """ + static let allCases: [ExcerptTestCase] = [ + // Spanish + ExcerptTestCase(title: "Spanish - Short Engaging", content: IntelligenceTestData.spanishRecipePost, length: .short, style: .engaging, language: "Spanish"), + ExcerptTestCase(title: "Spanish - Medium Professional", content: IntelligenceTestData.spanishTechPost, length: .medium, style: .professional, language: "Spanish"), + ExcerptTestCase(title: "Spanish - Long Conversational", content: IntelligenceTestData.spanishTravelPost, length: .long, style: .conversational, language: "Spanish"), + + // English + ExcerptTestCase(title: "English - Short Witty", content: IntelligenceTestData.englishTechPost, length: .short, style: .witty, language: "English"), + ExcerptTestCase(title: "English - Medium Formal", content: IntelligenceTestData.englishAcademicPost, length: .medium, style: .formal, language: "English"), + ExcerptTestCase(title: "English - Long Engaging", content: IntelligenceTestData.englishStoryPost, length: .long, style: .engaging, language: "English"), + + // French + ExcerptTestCase(title: "French - Medium Engaging", content: IntelligenceTestData.frenchCulturePost, length: .medium, style: .engaging, language: "French"), + ExcerptTestCase(title: "French - Short Professional", content: IntelligenceTestData.frenchBusinessPost, length: .short, style: .professional, language: "French"), + + // Japanese + ExcerptTestCase(title: "Japanese - Medium Conversational", content: IntelligenceTestData.japaneseCookingPost, length: .medium, style: .conversational, language: "Japanese"), + ExcerptTestCase(title: "Japanese - Short Formal", content: IntelligenceTestData.japaneseBusinessPost, length: .short, style: .formal, language: "Japanese"), + + // German + ExcerptTestCase(title: "German - Medium Professional", content: IntelligenceTestData.germanTechPost, length: .medium, style: .professional, language: "German"), + ] } + +// MARK: - Test Data + diff --git a/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift index dc61af605726..ee1c4c3d0d3a 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift @@ -33,66 +33,28 @@ import Testing @Suite(.disabled("Manual tests - Run individually to test post summaries")) struct IntelligencePostSummaryTests { - @available(iOS 26, *) - @Test("Summarize Spanish post") - func spanishContent() async throws { - let summary = try await summarizePost(content: TestData.spanishPost) - - IntelligenceTestHelpers.printSummaryResults( - "Spanish Post Summary", - summary: summary, - language: "Spanish" - ) - - #expect(!summary.isEmpty, "Summary should not be empty") - } + // MARK: - Parameterized Language Tests @available(iOS 26, *) - @Test("Summarize English post") - func englishContent() async throws { - let summary = try await summarizePost(content: TestData.englishPost) + @Test(arguments: SummaryTestCase.basicCases) + func postSummary(testCase: SummaryTestCase) async throws { + let summary = try await summarizePost(content: testCase.content) IntelligenceTestHelpers.printSummaryResults( - "English Post Summary", + testCase.title, summary: summary, - language: "English" + language: testCase.language ) #expect(!summary.isEmpty, "Summary should not be empty") } - @available(iOS 26, *) - @Test("Summarize French post") - func frenchContent() async throws { - let summary = try await summarizePost(content: TestData.frenchPost) - - IntelligenceTestHelpers.printSummaryResults( - "French Post Summary", - summary: summary, - language: "French" - ) - - #expect(!summary.isEmpty, "Summary should not be empty") - } - - @available(iOS 26, *) - @Test("Summarize Japanese post") - func japaneseContent() async throws { - let summary = try await summarizePost(content: TestData.japanesePost) - - IntelligenceTestHelpers.printSummaryResults( - "Japanese Post Summary", - summary: summary, - language: "Japanese" - ) - - #expect(!summary.isEmpty, "Summary should not be empty") - } + // MARK: - Special Content Tests @available(iOS 26, *) @Test("Summarize HTML-heavy Spanish post") func spanishHTMLContent() async throws { - let summary = try await summarizePost(content: TestData.spanishPostWithHTML) + let summary = try await summarizePost(content: IntelligenceTestData.spanishPostWithHTML) IntelligenceTestHelpers.printSummaryResults( "Spanish HTML Content Summary", @@ -106,7 +68,7 @@ struct IntelligencePostSummaryTests { @available(iOS 26, *) @Test("Reader summary for Spanish article") func readerSummarySpanishContent() async throws { - let summary = try await summarizePost(content: TestData.spanishReaderArticle) + let summary = try await summarizePost(content: IntelligenceTestData.spanishReaderArticle) IntelligenceTestHelpers.printSummaryResults( "Spanish Reader Article Summary", @@ -120,7 +82,7 @@ struct IntelligencePostSummaryTests { @available(iOS 26, *) @Test("Reader summary for English article") func readerSummaryEnglishContent() async throws { - let summary = try await summarizePost(content: TestData.englishReaderArticle) + let summary = try await summarizePost(content: IntelligenceTestData.englishReaderArticle) IntelligenceTestHelpers.printSummaryResults( "English Reader Article Summary", @@ -147,106 +109,20 @@ struct IntelligencePostSummaryTests { } } -// MARK: - Test Data - -private enum TestData { - static let spanishPost = """ - La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. - Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad - de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. - - La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. - El azafrán no solo aporta ese característico color dorado, sino también un sabor único - e inconfundible. - - Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la - variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para - conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. - """ - - static let spanishPostWithHTML = """ - -

Receta Auténtica de Paella Valenciana

- - - -

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

- - - -

Ingredientes Principales

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

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

- - """ - - static let spanishReaderArticle = """ - 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. - """ - - static let englishPost = """ - Sourdough bread has experienced a remarkable revival in recent years, with home bakers - around the world rediscovering this ancient craft. The natural fermentation process - creates a distinctive tangy flavor and numerous health benefits. - - The key to successful sourdough lies in maintaining a healthy starter culture. This - living mixture of flour and water harbors wild yeast and beneficial bacteria that - work together to leaven the bread and develop complex flavors. - - Temperature and timing are crucial factors. The fermentation process can take anywhere - from 12 to 24 hours, depending on ambient temperature and the activity of your starter. - """ - - static let englishReaderArticle = """ - 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. - """ - - static let frenchPost = """ - 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. - """ - - static let japanesePost = """ - 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 - この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 - - 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 - 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 - - 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 - インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 - """ +// MARK: - Test Cases + +private struct SummaryTestCase: CustomTestStringConvertible { + let title: String + let content: String + let language: String + + var testDescription: String { title } + + static let basicCases: [SummaryTestCase] = [ + SummaryTestCase(title: "Spanish Post Summary", content: IntelligenceTestData.spanishPost, language: "Spanish"), + SummaryTestCase(title: "English Post Summary", content: IntelligenceTestData.englishPost, language: "English"), + SummaryTestCase(title: "French Post Summary", content: IntelligenceTestData.frenchPost, language: "French"), + SummaryTestCase(title: "Japanese Post Summary", content: IntelligenceTestData.japanesePost, language: "Japanese"), + ] } + diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift index e19ca2aacaa4..233b025eb585 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift @@ -40,109 +40,33 @@ import Testing @Suite(.disabled("Manual tests - Run individually to test tag suggestions")) struct IntelligenceSuggestedTagsTests { - @available(iOS 26, *) - @Test("Spanish content with Spanish site tags") - func spanishContentSpanishTags() async throws { - let tags = try await suggestTags( - post: TestData.spanishPost, - siteTags: TestData.spanishSiteTags, - postTags: [] - ) - - IntelligenceTestHelpers.printTagResults( - "Spanish Content → Spanish Site Tags", - tags: tags, - language: "Spanish" - ) - } - - @available(iOS 26, *) - @Test("Spanish content with English site tags") - func spanishContentEnglishTags() async throws { - let tags = try await suggestTags( - post: TestData.spanishPost, - siteTags: TestData.englishSiteTags, - postTags: [] - ) - - IntelligenceTestHelpers.printTagResults( - "Spanish Content → English Site Tags", - tags: tags, - language: "English" - ) - } - - @available(iOS 26, *) - @Test("English content with Spanish site tags") - func englishContentSpanishTags() async throws { - let tags = try await suggestTags( - post: TestData.englishPost, - siteTags: TestData.spanishSiteTags, - postTags: [] - ) - - IntelligenceTestHelpers.printTagResults( - "English Content → Spanish Site Tags", - tags: tags, - language: "Spanish" - ) - } - - @available(iOS 26, *) - @Test("Mixed language content") - func mixedLanguageContent() async throws { - let tags = try await suggestTags( - post: TestData.mixedLanguagePost, - siteTags: TestData.englishSiteTags, - postTags: [] - ) - - IntelligenceTestHelpers.printTagResults( - "Mixed Language Content", - tags: tags, - language: "Dominant language" - ) - } + // MARK: - Parameterized Language Combination Tests @available(iOS 26, *) - @Test("French content") - func frenchContent() async throws { + @Test(arguments: TagTestCase.languageCombinations) + func tagSuggestion(testCase: TagTestCase) async throws { let tags = try await suggestTags( - post: TestData.frenchPost, - siteTags: TestData.frenchSiteTags, - postTags: [] + post: testCase.post, + siteTags: testCase.siteTags, + postTags: testCase.postTags ) IntelligenceTestHelpers.printTagResults( - "French Content → French Site Tags", + testCase.title, tags: tags, - language: "French" + language: testCase.language ) } - @available(iOS 26, *) - @Test("Japanese content") - func japaneseContent() async throws { - let tags = try await suggestTags( - post: TestData.japanesePost, - siteTags: TestData.japaneseSiteTags, - postTags: [] - ) - - IntelligenceTestHelpers.printTagResults( - "Japanese Content → Japanese Site Tags", - tags: tags, - language: "Japanese" - ) - } + // MARK: - Edge Case Tests @available(iOS 26, *) @Test("Existing post tags should be excluded") func excludeExistingTags() async throws { let existingTags = ["recetas", "cocina"] let tags = try await suggestTags( - post: TestData.spanishPost, - siteTags: TestData.spanishSiteTags, + post: IntelligenceTestData.spanishPost, + siteTags: IntelligenceTestData.spanishSiteTags, postTags: existingTags ) @@ -159,7 +83,7 @@ struct IntelligenceSuggestedTagsTests { @Test("Empty site tags") func emptySiteTags() async throws { let tags = try await suggestTags( - post: TestData.spanishPost, + post: IntelligenceTestData.spanishPost, siteTags: [], postTags: [] ) @@ -176,7 +100,7 @@ struct IntelligenceSuggestedTagsTests { func shortContent() async throws { let tags = try await suggestTags( post: "Deliciosa receta de gazpacho andaluz.", - siteTags: TestData.spanishSiteTags, + siteTags: IntelligenceTestData.spanishSiteTags, postTags: [] ) @@ -190,10 +114,10 @@ struct IntelligenceSuggestedTagsTests { @available(iOS 26, *) @Test("Very long content") func longContent() async throws { - let longContent = String(repeating: TestData.spanishPost + "\n\n", count: 5) + let longContent = String(repeating: IntelligenceTestData.spanishPost + "\n\n", count: 5) let tags = try await suggestTags( post: longContent, - siteTags: TestData.spanishSiteTags, + siteTags: IntelligenceTestData.spanishSiteTags, postTags: [] ) @@ -216,112 +140,24 @@ struct IntelligenceSuggestedTagsTests { } } -// MARK: - Test Data - -private enum TestData { - // MARK: Spanish Content - - static let spanishPost = """ - La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. - Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad - de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. - - La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. - El azafrán no solo aporta ese característico color dorado, sino también un sabor único - e inconfundible. - - Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la - variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para - conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. - """ - - static let spanishSiteTags = [ - "recetas", - "cocina-española", - "gastronomía", - "comida-mediterránea", - "platos-tradicionales" - ] - - // MARK: English Content - - static let englishPost = """ - Sourdough bread has experienced a remarkable revival in recent years, with home bakers - around the world rediscovering this ancient craft. The natural fermentation process - creates a distinctive tangy flavor and numerous health benefits. - - The key to successful sourdough lies in maintaining a healthy starter culture. This - living mixture of flour and water harbors wild yeast and beneficial bacteria that - work together to leaven the bread and develop complex flavors. - - Temperature and timing are crucial factors. The fermentation process can take anywhere - from 12 to 24 hours, depending on ambient temperature and the activity of your starter. - """ - - static let englishSiteTags = [ - "baking", - "bread-making", - "recipes", - "sourdough", - "homemade" - ] - - // MARK: French Content - - static let frenchPost = """ - 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. +// MARK: - Test Cases - 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. +private struct TagTestCase: CustomTestStringConvertible { + let title: String + let post: String + let siteTags: [String] + let postTags: [String] + let language: String - 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. - """ + var testDescription: String { title } - static let frenchSiteTags = [ - "cuisine", - "gastronomie-française", - "recettes", - "plats-traditionnels", - "art-culinaire" + static let languageCombinations: [TagTestCase] = [ + TagTestCase(title: "Spanish Content → Spanish Site Tags", post: IntelligenceTestData.spanishPost, siteTags: IntelligenceTestData.spanishSiteTags, postTags: [], language: "Spanish"), + TagTestCase(title: "Spanish Content → English Site Tags", post: IntelligenceTestData.spanishPost, siteTags: IntelligenceTestData.englishSiteTags, postTags: [], language: "English"), + TagTestCase(title: "English Content → Spanish Site Tags", post: IntelligenceTestData.englishPost, siteTags: IntelligenceTestData.spanishSiteTags, postTags: [], language: "Spanish"), + TagTestCase(title: "Mixed Language Content", post: IntelligenceTestData.mixedLanguagePost, siteTags: IntelligenceTestData.englishSiteTags, postTags: [], language: "Dominant language"), + TagTestCase(title: "French Content → French Site Tags", post: IntelligenceTestData.frenchPost, siteTags: IntelligenceTestData.frenchSiteTags, postTags: [], language: "French"), + TagTestCase(title: "Japanese Content → Japanese Site Tags", post: IntelligenceTestData.japanesePost, siteTags: IntelligenceTestData.japaneseSiteTags, postTags: [], language: "Japanese"), ] - - // MARK: Japanese Content - - static let japanesePost = """ - 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 - この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 - - 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 - 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 - - 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 - インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 - """ - - static let japaneseSiteTags = [ - "日本料理", - "レシピ", - "料理", - "伝統", - "和食" - ] - - // MARK: Mixed Language Content - - static let mixedLanguagePost = """ - 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. - """ } + diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceTestData.swift b/Modules/Tests/WordPressSharedTests/IntelligenceTestData.swift new file mode 100644 index 000000000000..d7043e01a50b --- /dev/null +++ b/Modules/Tests/WordPressSharedTests/IntelligenceTestData.swift @@ -0,0 +1,319 @@ +import Foundation + +/// 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 IntelligenceTestData { + // MARK: - Spanish Content + + static let spanishRecipePost = """ + La auténtica tortilla española es mucho más que simplemente huevos y patatas. Es un arte + culinario que requiere paciencia, técnica y los ingredientes adecuados. La clave está en + lograr el equilibrio perfecto: patatas tiernas pero no deshechas, huevos jugosos en el centro, + y esa capa exterior ligeramente dorada que aporta textura. + + El secreto mejor guardado de las abuelas españolas es la temperatura del aceite. Debe estar + lo suficientemente caliente para cocinar las patatas, pero no tanto como para dorarlas rápidamente. + Este proceso lento permite que las patatas se impregnen del aceite de oliva, creando esa textura + cremosa característica. + + Algunos puristas insisten en que solo debe llevar patatas, huevos, cebolla y sal. Otros + defienden versiones más innovadoras con chorizo, pimientos o incluso espinacas. Sin embargo, + la receta tradicional valenciana ha resistido el paso del tiempo por una razón: su simplicidad + permite que brillen los sabores fundamentales. + """ + + static let spanishTechPost = """ + La inteligencia artificial generativa ha revolucionado la forma en que interactuamos con la + tecnología. Modelos de lenguaje como GPT-4 y Claude han demostrado capacidades sorprendentes + en tareas que van desde la redacción creativa hasta el análisis de código complejo. + + Sin embargo, esta transformación digital plantea importantes cuestiones éticas. ¿Cómo garantizamos + que estos sistemas sean justos e imparciales? ¿Qué medidas debemos implementar para proteger + la privacidad de los datos? ¿Cómo equilibramos la automatización con la preservación del empleo? + + Los expertos coinciden en que necesitamos un marco regulatorio robusto que fomente la innovación + mientras protege los derechos fundamentales. La Unión Europea ha dado un paso importante con su + Ley de IA, estableciendo un precedente para otras regiones del mundo. + """ + + static let spanishTravelPost = """ + Sevilla en primavera es una experiencia sensorial incomparable. Las calles estrechas del barrio + de Santa Cruz se llenan del aroma de azahar, mientras el sonido de las guitarras flamencas + resuena desde los patios escondidos. Durante la Feria de Abril, la ciudad se transforma en un + espectáculo de color, música y tradición que deja sin aliento a los visitantes. + + Pasear por el Real Alcázar al atardecer es como retroceder en el tiempo. Los jardines moriscos, + con sus fuentes burbujeantes y naranjos centenarios, ofrecen un oasis de tranquilidad en medio + del bullicio urbano. La mezcla de arquitectura mudéjar, gótica y renacentista cuenta la rica + historia de una ciudad que ha sido encrucijada de culturas durante milenios. + + No se puede visitar Sevilla sin probar las tapas auténticas. Olvidaos de los lugares turísticos + y aventuraos en los bares de barrio donde los locales se congregan. Un montadito de pringá, unas + espinacas con garbanzos, o el clásico salmorejo cordobés acompañados de un fino bien frío son + la verdadera esencia de la cultura sevillana. + """ + + static let spanishPostWithHTML = """ + +

Receta Auténtica de Paella Valenciana

+ + + +

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

+ + + +

Ingredientes Principales

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

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

+ + """ + + static let spanishPost = """ + La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. + Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad + de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. + + La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. + El azafrán no solo aporta ese característico color dorado, sino también un sabor único + e inconfundible. + + Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la + variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para + conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. + """ + + static let spanishReaderArticle = """ + El cambio climático está afectando de manera significativa a los ecosistemas marinos + del Mediterráneo. Científicos del CSIC han documentado un aumento de 2 grados en la + temperatura media del agua durante los últimos 30 años, lo que ha provocado cambios + en las rutas migratorias de varias especies de peces y la proliferación de especies + invasoras procedentes de aguas más cálidas. + """ + + // MARK: - English Content + + static let englishTechPost = """ + Quantum computing represents a paradigm shift in how we approach computational problems. Unlike + classical computers that use bits (0s and 1s), quantum computers leverage qubits that can exist + in superposition, simultaneously representing multiple states. + + This fundamental difference enables quantum computers to tackle problems that are intractable + for classical machines. Drug discovery, cryptography, optimization, and climate modeling are + just a few domains poised for revolutionary breakthroughs. + + However, significant challenges remain. Quantum systems are incredibly fragile, requiring + near-absolute-zero temperatures and isolation from environmental interference. Error correction + is another major hurdle, as quantum states are prone to decoherence. + """ + + static let englishAcademicPost = """ + The phenomenon of linguistic relativity, often referred to as the Sapir-Whorf hypothesis, + posits that the structure of a language influences its speakers' worldview and cognition. + While the strong version of this hypothesis has been largely discredited, contemporary research + suggests more nuanced relationships between language and thought. + + Recent studies in cognitive linguistics have demonstrated that language can indeed affect + perception and categorization, particularly in domains like color perception, spatial reasoning, + and temporal cognition. However, these effects are context-dependent and vary significantly + across different cognitive domains. + + Cross-linguistic research continues to provide valuable insights into the universal and + language-specific aspects of human cognition, challenging researchers to refine their + theoretical frameworks and methodological approaches. + """ + + static let englishStoryPost = """ + The old lighthouse keeper had seen many storms in his forty years tending the beacon, but + none quite like the tempest that rolled in that October evening. Dark clouds gathered on + the horizon like an invading army, their edges tinged with an unsettling green hue. + + As the first drops of rain pelted the lighthouse windows, Magnus checked the lamp one final + time. The beam cut through the gathering darkness, a lifeline for any vessels brave or foolish + enough to be out on such a night. He'd heard the coastguard warnings on the radio—winds + exceeding 90 miles per hour, waves reaching heights of 30 feet. + + Down in the keeper's quarters, Magnus brewed strong coffee and settled into his worn leather + chair. Outside, the wind howled like a wounded beast, but within these thick stone walls, + he felt safe. This lighthouse had withstood two centuries of nature's fury; it would stand + through one more night. + """ + + static let englishPost = """ + Sourdough bread has experienced a remarkable revival in recent years, with home bakers + around the world rediscovering this ancient craft. The natural fermentation process + creates a distinctive tangy flavor and numerous health benefits. + + The key to successful sourdough lies in maintaining a healthy starter culture. This + living mixture of flour and water harbors wild yeast and beneficial bacteria that + work together to leaven the bread and develop complex flavors. + + Temperature and timing are crucial factors. The fermentation process can take anywhere + from 12 to 24 hours, depending on ambient temperature and the activity of your starter. + """ + + static let englishReaderArticle = """ + Recent advances in quantum computing have brought us closer to solving complex problems + that are impossible for classical computers. Google's quantum processor achieved + quantum supremacy by performing a calculation in 200 seconds that would take the world's + fastest supercomputer 10,000 years to complete. However, practical applications for + everyday computing are still years away. + """ + + // MARK: - French Content + + static let frenchCulturePost = """ + Le café français est bien plus qu'une simple boisson; c'est une institution culturelle qui + incarne l'art de vivre à la française. Depuis les cafés littéraires de Saint-Germain-des-Prés + fréquentés par Sartre et de Beauvoir jusqu'aux bistrots de quartier où les habitués se + retrouvent chaque matin, ces établissements sont le cœur battant de la vie sociale française. + + L'architecture des cafés parisiens, avec leurs terrasses caractéristiques et leurs décors + Art nouveau ou Art déco, raconte l'histoire de la ville. Chaque café a son caractère unique, + son ambiance particulière, et ses réguliers qui y viennent non seulement pour le café, + mais surtout pour la conversation, l'observation, et cette délicieuse pause dans le rythme + effréné de la vie moderne. + """ + + static let frenchBusinessPost = """ + La transformation numérique des entreprises françaises s'accélère, portée par la nécessité + de rester compétitives dans un marché mondialisé. Les PME, longtemps réticentes à adopter + de nouvelles technologies, reconnaissent désormais l'importance cruciale de l'innovation + digitale pour leur survie et leur croissance. + + Les investissements dans l'intelligence artificielle, le cloud computing, et la cybersécurité + augmentent exponentiellement. Le gouvernement français soutient cette transition avec des + programmes d'accompagnement et des incitations fiscales, conscient que la compétitivité + future du pays en dépend. + """ + + static let frenchPost = """ + La cuisine française est reconnue mondialement pour sa finesse et sa diversité. + Du coq au vin bourguignon au délicieux cassoulet du Sud-Ouest, chaque région possède + ses spécialités qui racontent une histoire culinaire unique. + + Les techniques de base de la cuisine française, comme le mirepoix, le roux, et les + cinq sauces mères, constituent le fondement de nombreuses préparations classiques. + Ces méthodes transmises de génération en génération permettent de créer des plats + d'une grande complexité et raffinement. + + L'utilisation d'ingrédients frais et de saison est primordiale. Les marchés locaux + offrent une abondance de produits qui inspirent les chefs et les cuisiniers amateurs. + """ + + // MARK: - Japanese Content + + static let japaneseCookingPost = """ + 日本料理における包丁の技術は、単なる食材の切断以上の意味を持ちます。それは料理人の + 心と技が一体となる瞬間であり、何年もの修行を通じて磨かれる芸術形態です。 + + 寿司職人が魚を捌く際の手つきには、無駄な動きが一切ありません。一つ一つの動作が計算され、 + 食材の繊維を理解し、その特性を最大限に引き出すための最適な方法が選ばれています。 + この技術は「包丁さばき」と呼ばれ、日本料理の根幹を成すものです。 + + 家庭料理においても、適切な包丁の使い方を学ぶことは重要です。正しい持ち方、切り方を + マスターすることで、料理の効率が上がるだけでなく、食材の味わいも向上します。 + 包丁に対する敬意と理解が、美味しい料理への第一歩なのです。 + """ + + static let japaneseBusinessPost = """ + 日本企業のグローバル展開において、文化的な適応能力が成功の鍵となっています。 + 従来の日本的経営手法を維持しながら、現地の商習慣や消費者ニーズに柔軟に対応する + バランス感覚が求められています。 + + 特に人材マネジメントの分野では、年功序列や終身雇用といった伝統的なシステムと、 + 成果主義やダイバーシティといった現代的な価値観の融合が課題となっています。 + 多くの企業が試行錯誤を重ねながら、新しい時代に適した経営モデルを模索しています。 + """ + + static let japanesePost = """ + 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 + この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 + + 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 + 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 + + 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 + インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 + """ + + // MARK: - German Content + + static let germanTechPost = """ + Die deutsche Automobilindustrie steht vor einem beispiellosen Wandel. Der Übergang von + Verbrennungsmotoren zu Elektroantrieben erfordert nicht nur technologische Innovation, + sondern auch eine grundlegende Neuausrichtung der gesamten Wertschöpfungskette. + + Traditionelle Zulieferer müssen sich anpassen oder riskieren, obsolet zu werden. Gleichzeitig + entstehen neue Geschäftsmodelle rund um Batterietechnologie, Ladeinfrastruktur und + Software-definierte Fahrzeuge. Die Frage ist nicht mehr, ob dieser Wandel kommt, sondern + wie schnell deutsche Unternehmen sich anpassen können, um ihre führende Position in der + globalen Automobilbranche zu behalten. + """ + + // MARK: - Mixed Language Content + + static let mixedLanguagePost = """ + The Mediterranean Diet: Una Guía Completa + + The Mediterranean diet has been recognized by UNESCO as an Intangible Cultural Heritage + of Humanity. Esta dieta tradicional se basa en el consumo de aceite de oliva, frutas, + verduras, legumbres, y pescado. + + Los beneficios para la salud son numerosos: reduced risk of heart disease, mejor + control del peso, y longevidad aumentada. Studies have shown that people who follow + this diet tend to live longer and healthier lives. + """ + + // MARK: - Tag Data + + static let spanishSiteTags = [ + "recetas", + "cocina-española", + "gastronomía", + "comida-mediterránea", + "platos-tradicionales" + ] + + static let englishSiteTags = [ + "baking", + "bread-making", + "recipes", + "sourdough", + "homemade" + ] + + static let frenchSiteTags = [ + "cuisine", + "gastronomie-française", + "recettes", + "plats-traditionnels", + "art-culinaire" + ] + + static let japaneseSiteTags = [ + "日本料理", + "レシピ", + "料理", + "伝統", + "和食" + ] +} diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift b/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift index 51c4b4f7f9dd..ffb7e8649dde 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift +++ b/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift @@ -10,14 +10,15 @@ enum IntelligenceTestHelpers { tags: [String], language: String ) { - printSection(title) + printSectionHeader(title, language: language) - print("Language: \(language)") - print("Generated \(tags.count) tags:") + print("📑 Generated \(tags.count) tags:") + print() for (i, tag) in tags.enumerated() { print(" \(i + 1). \(tag)") } - print() + + printSectionFooter() } // MARK: - Summaries @@ -27,13 +28,17 @@ enum IntelligenceTestHelpers { summary: String, language: String ) { - printSection(title) + printSectionHeader(title, language: language) - print("Language: \(language)") let wordCount = summary.split(separator: " ").count - print("Summary (\(wordCount) words, \(summary.count) chars):") - print(summary) + let charCount = summary.count + print("📊 Metrics: \(wordCount) words • \(charCount) characters") print() + print("📝 Summary:") + print() + print(summary.wrapped(width: 80)) + + printSectionFooter() } // MARK: - Excerpts @@ -44,15 +49,22 @@ enum IntelligenceTestHelpers { targetLength: String, style: String ) { - printSection(title) + printSectionHeader(title) - print("Target: \(targetLength) words, Style: \(style)") - print("\nGenerated \(excerpts.count) variations:\n") + print("🎯 Target: \(targetLength) • Style: \(style)") + print() + print("📝 Generated \(excerpts.count) variations:") + print() for (i, excerpt) in excerpts.enumerated() { let wordCount = excerpt.split(separator: " ").count - print("[\(i + 1)] (\(wordCount) words)") - print(excerpt) + print("┌─ Variation \(i + 1) (\(wordCount) words) " + String(repeating: "─", count: max(0, 70 - "\(i + 1)".count - "\(wordCount)".count))) + print("│") + for line in excerpt.wrapped(width: 78).split(separator: "\n") { + print("│ \(line)") + } + print("│") + print("└" + String(repeating: "─", count: 78)) print() } } @@ -64,7 +76,7 @@ enum IntelligenceTestHelpers { headers: [String], rows: [[String]] ) { - printSection(title) + printSectionHeader(title) // Calculate column widths var widths = headers.map { $0.count } @@ -75,29 +87,111 @@ enum IntelligenceTestHelpers { } // 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: i < headers.count - 1 ? " │ " : "\n") + 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), terminator: i < widths.count - 1 ? "─┼─" : "\n") + 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: i < row.count - 1 ? " │ " : "\n") + print(" \(cell.padding(toLength: widths[i], withPad: " ", startingAt: 0)) ", terminator: "") + print(i < row.count - 1 ? "│" : "│\n", terminator: "") } } - print() + + // 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 printSection(_ title: String) { - print("\n" + String(repeating: "─", count: 80)) - print(title) - print(String(repeating: "─", count: 80)) + private static func printSectionHeader(_ title: String, language: String? = nil) { + print() + print("╔" + String(repeating: "═", count: 78) + "╗") + + if let language = language { + let flag = languageFlag(for: language) + print("║ \(flag) \(title)") + } else { + print("║ \(title)") + } + + print("╠" + String(repeating: "═", count: 78) + "╣") + print() + } + + private static func printSectionFooter() { + print("╚" + String(repeating: "═", count: 78) + "╝") + print() + } + + private static func languageFlag(for language: String) -> String { + switch language.lowercased() { + case "spanish": return "🇪🇸" + case "english": return "🇬🇧" + case "french": return "🇫🇷" + case "japanese": return "🇯🇵" + case "german": return "🇩🇪" + case "dominant language": return "🌐" + default: return "🌍" + } + } +} + +// MARK: - String Extensions + +private extension String { + /// Wraps text to specified width while preserving words + func wrapped(width: Int) -> String { + var result = "" + var currentLine = "" + var currentWidth = 0 + + for word in self.split(separator: " ") { + let wordWidth = word.count + + if currentWidth + wordWidth + 1 > width { + if !result.isEmpty { + result += "\n" + } + result += currentLine.trimmingCharacters(in: .whitespaces) + currentLine = String(word) + " " + currentWidth = wordWidth + 1 + } else { + currentLine += word + " " + currentWidth += wordWidth + 1 + } + } + + if !currentLine.isEmpty { + if !result.isEmpty { + result += "\n" + } + result += currentLine.trimmingCharacters(in: .whitespaces) + } + + return result } } From 9745be5641af8475355346ad2088dd339ffd622e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 4 Dec 2025 09:23:26 -0500 Subject: [PATCH 06/13] Remove README files --- .../EXAMPLE_TEST_OUTPUT.md | 210 ------------------ .../README_LOCALE_TESTS.md | 193 ---------------- 2 files changed, 403 deletions(-) delete mode 100644 Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md delete mode 100644 Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md diff --git a/Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md b/Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md deleted file mode 100644 index 3f53269dfc80..000000000000 --- a/Modules/Tests/WordPressSharedTests/EXAMPLE_TEST_OUTPUT.md +++ /dev/null @@ -1,210 +0,0 @@ -# Example Test Output - -This document shows what the structured test output looks like when running intelligence tests. - -## Tag Suggestions Output - -``` -════════════════════════════════════════════════════════════════════════════════ -🏷️ Spanish Content → Spanish Site Tags -════════════════════════════════════════════════════════════════════════════════ - -📥 INPUT - Content: La paella valenciana es uno de los platos más emblemáticos de la gastronomía española... - Content Language: Spanish - Site Tags: recetas, cocina-española, gastronomía, comida-mediterránea, platos-tradicionales - -📤 OUTPUT - Generated Tags (8): - 1. arroz - 2. paella-valenciana - 3. azafrán - 4. cocina-tradicional - 5. receta-española - 6. comida-casera - 7. arroz-bomba - 8. sofrito - -✓ VERIFICATION CHECKLIST - • All tags are in Spanish - • Tags use lowercase-hyphenated format (e.g., 'cocina-española') - • Tags are relevant to paella/Spanish cooking - • No duplicate tags - -════════════════════════════════════════════════════════════════════════════════ -``` - -## Post Summary Output - -``` -════════════════════════════════════════════════════════════════════════════════ -📝 Spanish Post Summary -════════════════════════════════════════════════════════════════════════════════ - -📥 INPUT - Content Language: Spanish - Content Type: Recipe/Cooking - Preview: La paella valenciana es uno de los platos más emblemáticos de la gastronomía española... - Length: 823 characters - -📤 OUTPUT - La paella valenciana es un plato emblemático de la gastronomía española que combina - arroz, azafrán y diversos ingredientes como pollo, conejo y verduras. El éxito de - este plato tradicional radica en la técnica del sofrito inicial y el punto preciso - del arroz. El uso de caldo casero de calidad y arroz bomba es fundamental, junto - con un control cuidadoso del fuego para lograr el socarrat, esa capa crujiente - característica que se forma en el fondo de la paellera. - ... (2 more lines) - Length: 543 characters, 87 words - -✓ VERIFICATION CHECKLIST - • Summary is in Spanish - • Captures main points about paella - • Concise and informative - • Neutral tone - -════════════════════════════════════════════════════════════════════════════════ -``` - -## Excerpt Generation Output - -``` -════════════════════════════════════════════════════════════════════════════════ -✍️ Spanish Excerpt - Short Engaging -════════════════════════════════════════════════════════════════════════════════ - -📥 INPUT - Content Language: Spanish - Target Length: 20-40 words - Style: Engaging - Preview: La auténtica tortilla española es mucho más que simplemente huevos y patatas... - -📤 OUTPUT - Generated 3 excerpt variations: - - [1] (32 words) - Descubre los secretos de la auténtica tortilla española: el arte de lograr patatas - perfectamente cremosas y huevos jugosos. Aprende la técnica tradicional que las - abuelas españolas han perfeccionado durante generaciones. - - [2] (28 words) - La tortilla española perfecta requiere paciencia y técnica. Conoce el equilibrio - ideal entre patatas tiernas, huevos cremosos y esa capa dorada exterior que hace - la diferencia. - - [3] (35 words) - ¿Quieres dominar el arte de la tortilla española? Te revelamos el secreto mejor - guardado: la temperatura exacta del aceite que transforma simples ingredientes en - una obra maestra culinaria con textura cremosa irresistible. - -✓ VERIFICATION CHECKLIST - • All excerpts are in Spanish - • Each excerpt is 20-40 words long - • Tone is engaging and compelling - • Focus on value, not just summary - • No ellipsis (...) at the end - • Works as standalone content - -════════════════════════════════════════════════════════════════════════════════ -``` - -## Comparison Table Output (All Styles) - -``` -════════════════════════════════════════════════════════════════════════════════ -📊 Spanish Excerpts - All Styles Comparison (Medium Length) -════════════════════════════════════════════════════════════════════════════════ - -Style │ Length │ Preview -─────────────────┼─────────────┼────────────────────────────────────────────────────────────── -Engaging │ 58 words │ Descubre los secretos de la auténtica tortilla españo... -Conversational │ 62 words │ ¿Sabes qué hace especial a una tortilla española? Te l... -Witty │ 55 words │ La tortilla española: más complicada que decidir quié... -Formal │ 67 words │ La preparación de la tortilla española requiere un co... -Professional │ 64 words │ La tortilla española constituye un elemento fundament... - -✓ VERIFICATION CHECKLIST - • Each style has distinct tone - • All excerpts are 50-70 words - • All excerpts are in Spanish - • Content is relevant to Spanish tortilla recipe - -════════════════════════════════════════════════════════════════════════════════ -``` - -## Comparison Table Output (All Lengths) - -``` -════════════════════════════════════════════════════════════════════════════════ -📊 English Excerpts - All Lengths Comparison (Engaging Style) -════════════════════════════════════════════════════════════════════════════════ - -Length │ Expected │ Actual │ Preview -─────────┼─────────────┼─────────┼────────────────────────────────────────────────────── -Short │ 20-40 │ 35 │ Quantum computing represents a paradigm shift in comp... -Medium │ 50-70 │ 63 │ Quantum computers leverage qubits in superposition, e... -Long │ 120-180 │ 156 │ Unlike classical computers using binary bits, quantum... - -✓ VERIFICATION CHECKLIST - • Each length matches expected word count range - • All excerpts are in English - • Tone is engaging throughout - • Content is relevant to quantum computing - -════════════════════════════════════════════════════════════════════════════════ -``` - -## Benefits of Structured Output - -### 1. **Clear Sections** -- Input parameters clearly separated from output -- Easy to see what was tested - -### 2. **Verification Checklists** -- Specific criteria to check -- Reduces manual verification effort -- Ensures consistency - -### 3. **Comparison Tables** -- Side-by-side comparison of multiple results -- Easy to spot differences in style/length -- Quick overview of variations - -### 4. **Visual Separators** -- Clean, professional appearance -- Easy to scan in console output -- Clear test boundaries - -### 5. **Metadata** -- Word/character counts -- Content previews -- Language indicators - -## Usage Pattern - -All tests now follow this pattern: - -```swift -@Test("Spanish content with Spanish site tags") -func spanishContentSpanishTags() async throws { - // 1. Execute the test - let tags = try await suggestTags(...) - - // 2. Print structured results - IntelligenceTestHelpers.printTagResults( - title: "Spanish Content → Spanish Site Tags", - input: .init(...), - output: tags, - expectations: [ - "All tags are in Spanish", - "Tags use lowercase-hyphenated format", - // ... - ] - ) - - // 3. Optional assertions - #expect(!tags.isEmpty) -} -``` - -This makes manual verification quick and efficient while maintaining clear documentation of expected behavior. diff --git a/Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md b/Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md deleted file mode 100644 index aa802680cab7..000000000000 --- a/Modules/Tests/WordPressSharedTests/README_LOCALE_TESTS.md +++ /dev/null @@ -1,193 +0,0 @@ -# Intelligence Service Locale Tests - -This directory contains comprehensive tests for verifying locale support in the intelligence services, organized by feature. - -## Test Files - -### IntelligenceSuggestedTagsTests.swift -Tests for tag suggestions across different languages: -- 10 test cases covering Spanish, English, French, Japanese, and mixed languages -- Tests with matching/mismatching site tags and content languages -- Edge cases: empty site tags, very short/long content - -**Suite status**: Disabled (run tests individually) - -### IntelligencePostSummaryTests.swift -Tests for post and reader summaries: -- 7 test cases for full post summarization and reader-style summaries -- Covers Spanish, English, French, and Japanese content -- HTML content extraction and summarization - -**Suite status**: Disabled (run tests individually) - -### IntelligenceExcerptGenerationTests.swift -Tests for excerpt generation with various styles and lengths: -- 17 test cases covering all 5 styles and 3 lengths -- Multiple languages: Spanish, English, French, Japanese, German -- Comprehensive tests for all style/length combinations -- HTML content and edge cases - -**Suite status**: Disabled (run tests individually) - -## Running the Tests - -### Enabling Test Suites - -Each test file uses `@Suite(.disabled(...))` to disable the entire suite. To run tests: - -**Option 1: Run Individual Tests** -1. Open the test file in Xcode -2. Click the diamond icon next to any specific test method -3. Only that test will run - -**Option 2: Enable Entire Suite Temporarily** -1. Comment out the `.disabled(...)` trait on the `@Suite` attribute -2. Run all tests in the suite -3. Remember to re-disable before committing - -**Option 3: Run from Test Navigator** -1. Open Test Navigator (⌘6) -2. Find the specific test you want to run -3. Click the play button next to it - -### Testing with Different Locales - -To test how the AI responds to different system locales: - -1. **Edit Scheme**: - - Product → Scheme → Edit Scheme... - - Select "Test" in the left sidebar - - Go to the "Options" tab - - Under "Application Language", select the desired language - -2. **Run Tests**: - - Execute individual tests or the entire suite - - The AI will receive locale context based on your selection - -3. **Verify Results**: - - Check the console output for generated content - - Verify the language, tone, and format match expectations - -## Test Coverage - -### Language Combinations -- ✅ Spanish content → Spanish output -- ✅ English content → English output -- ✅ French content → French output -- ✅ Japanese content → Japanese output -- ✅ German content → German output -- ✅ Mixed language content -- ✅ Cross-language scenarios (Spanish content with English tags, etc.) - -### Content Types -- Recipe posts (Spanish, Japanese) -- Tech articles (Spanish, English, German) -- Travel posts (Spanish) -- Academic posts (English) -- Story posts (English) -- Culture posts (French) -- Business posts (French, Japanese) - -### Excerpt Styles (All tested) -- Engaging -- Conversational -- Witty -- Formal -- Professional - -### Excerpt Lengths (All tested) -- Short (20-40 words) -- Medium (50-70 words) -- Long (120-180 words) - -## Expected Behavior - -### Tag Suggestions -- Should match the language of site tags when available -- Should match the formatting pattern (lowercase-hyphenated, etc.) -- Should exclude existing post tags -- Should be relevant to content - -### Post Summaries -- Should be in the same language as the content -- Should capture main points concisely -- Should stream results progressively - -### Excerpts -- Should match specified length (word count) -- Should match specified style (tone) -- Should be in the same language as content -- Should follow WordPress excerpt best practices -- Should work as standalone content - -## Error Handling - -If tests are run on iOS < 26, they will throw: -``` -TestError.unsupportedIOSVersion: "Feature requires iOS 26 or later. -Current iOS version does not support these features." -``` - -This is expected behavior - the intelligence services require iOS 26+. - -## File Organization - -Tests are organized by **feature** rather than by language or scenario: - -``` -IntelligenceSuggestedTagsTests.swift → All tag suggestion scenarios -IntelligencePostSummaryTests.swift → All summarization scenarios -IntelligenceExcerptGenerationTests.swift → All excerpt generation scenarios -``` - -Each file: -- Uses `@Suite(.disabled(...))` to disable the entire suite -- Has focused test names without redundant prefixes -- Contains only the test data needed for that feature -- Includes dedicated helper methods with runtime availability checks - -## Debugging - -If tests fail or produce unexpected results: - -1. **Check console output** for the full AI response -2. **Verify locale** is set correctly in the scheme -3. **Review test data** to ensure it's appropriate -4. **Check rate limiting** if running many tests quickly -5. **Verify iOS 26+** is available (required for FoundationModels) -6. **Inspect errors** - runtime availability checks provide clear error messages - -## Adding New Tests - -To add new test scenarios: - -1. Choose the appropriate test file based on feature -2. Add test data to the private `TestData` or `ExcerptTestData` enum -3. Create a new test method with a descriptive name -4. Use the existing helper methods for service calls -5. Include expected behavior in print statements -6. Add appropriate assertions using `#expect()` - -Example: -```swift -@Test("German content with French site tags") -func germanContentFrenchTags() async throws { - let tags = try await suggestTags( - post: TestData.germanPost, - siteTags: TestData.frenchSiteTags, - postTags: [] - ) - - print("Generated tags:") - print(tags.joined(separator: ", ")) - print("\nExpected: Tags in French matching site tag format") -} -``` - -## Notes - -- All test suites are disabled by default -- Runtime availability checks ensure proper error handling on iOS < 26 -- Test data is localized and realistic for accurate testing -- Helper methods handle all platform-specific API calls -- The system locale affects AI output even when content is in a different language From 320b3c62174e020904820d52fee965951ff80403 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 4 Dec 2025 10:03:21 -0500 Subject: [PATCH 07/13] Move Style --- .../Intelligence/IntelligenceService.swift | 40 +++++++++++++++++ .../Intelligence/LanguageModelHelper.swift | 2 +- .../UseCases/ExcerptGeneration.swift | 43 +++---------------- .../IntelligenceExcerptGenerationTests.swift | 4 +- .../IntelligencePostSummaryTests.swift | 2 +- .../IntelligenceServiceTests.swift | 0 .../IntelligenceSuggestedTagsTests.swift | 2 +- .../IntelligenceTestData.swift | 0 .../IntelligenceTestHelpers.swift | 0 .../IntelligenceUtilitiesTests.swift | 0 10 files changed, 50 insertions(+), 43 deletions(-) rename Modules/Tests/WordPressSharedTests/{ => Intelligence}/IntelligenceExcerptGenerationTests.swift (98%) rename Modules/Tests/WordPressSharedTests/{ => Intelligence}/IntelligencePostSummaryTests.swift (98%) rename Modules/Tests/WordPressSharedTests/{ => Intelligence}/IntelligenceServiceTests.swift (100%) rename Modules/Tests/WordPressSharedTests/{ => Intelligence}/IntelligenceSuggestedTagsTests.swift (99%) rename Modules/Tests/WordPressSharedTests/{ => Intelligence}/IntelligenceTestData.swift (100%) rename Modules/Tests/WordPressSharedTests/{ => Intelligence}/IntelligenceTestHelpers.swift (100%) rename Modules/Tests/WordPressSharedTests/{ => Intelligence}/IntelligenceUtilitiesTests.swift (100%) diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift index 8c35133b1e6b..f18c51403725 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift @@ -69,4 +69,44 @@ public actor IntelligenceService { let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio return String((extract ?? post).prefix(Int(postSizeLimit))) } + + // MARK: - Shared Parameters + + /// Writing style for generated excerpts. + public enum WritingStyle: String, CaseIterable, Sendable { + 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") + } + } + + 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/WordPressShared/Intelligence/LanguageModelHelper.swift b/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift index 27148c6759c9..b44b7fdcce2f 100644 --- a/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift +++ b/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift @@ -5,7 +5,7 @@ import FoundationModels // These will be removed once all usages are migrated to IntelligenceService nested types @available(iOS 26, *) -public typealias GenerationStyle = IntelligenceService.ExcerptGeneration.Style +public typealias GenerationStyle = IntelligenceService.WritingStyle @available(iOS 26, *) public typealias GeneratedContentLength = IntelligenceService.ExcerptGeneration.Length diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift index a073e616d9a6..b384ce5bbf06 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift @@ -1,7 +1,6 @@ import Foundation import FoundationModels -@available(iOS 26, *) extension IntelligenceService { /// Excerpt generation for WordPress posts. /// @@ -9,43 +8,7 @@ extension IntelligenceService { /// length and writing style. Supports session-based usage (for UI with continuity) /// and one-shot generation (for tests and background tasks). public enum ExcerptGeneration { - /// Writing style for generated excerpts. - public enum Style: String, CaseIterable, Sendable { - 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 typealias Style = IntelligenceService.WritingStyle /// Target length for generated excerpts. public enum Length: Int, CaseIterable, Sendable { @@ -141,6 +104,9 @@ extension IntelligenceService { length: Length, style: Style ) async throws -> [String] { + guard #available(iOS 26, *) else { + throw URLError(.unknown) + } let extractedContent = IntelligenceService.extractRelevantText(from: content) let session = LanguageModelSession( @@ -157,6 +123,7 @@ extension IntelligenceService { return response.content.excerpts } + @available(iOS 26, *) @Generable public struct Result { @Guide(description: "Three different excerpt variations") diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift similarity index 98% rename from Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift rename to Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift index c3c986cf59b8..3b35910bbc0d 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift @@ -143,7 +143,7 @@ struct IntelligenceExcerptGenerationTests { @Test("Spanish HTML content") func spanishHTMLContent() async throws { let excerpts = try await generateExcerpts( - content: IntelligenceTestData.spanishHTMLPost, + content: IntelligenceTestData.spanishPostWithHTML, length: .medium, style: .engaging ) @@ -218,7 +218,7 @@ struct IntelligenceExcerptGenerationTests { // MARK: - Test Cases -private struct ExcerptTestCase: CustomTestStringConvertible { +struct ExcerptTestCase: CustomTestStringConvertible { let title: String let content: String let length: IntelligenceService.ExcerptGeneration.Length diff --git a/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift similarity index 98% rename from Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift rename to Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift index ee1c4c3d0d3a..a8dc00e73e71 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligencePostSummaryTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift @@ -111,7 +111,7 @@ struct IntelligencePostSummaryTests { // MARK: - Test Cases -private struct SummaryTestCase: CustomTestStringConvertible { +struct SummaryTestCase: CustomTestStringConvertible { let title: String let content: String let language: String diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceServiceTests.swift similarity index 100% rename from Modules/Tests/WordPressSharedTests/IntelligenceServiceTests.swift rename to Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceServiceTests.swift diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift similarity index 99% rename from Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift rename to Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift index 233b025eb585..0a0621586b3d 100644 --- a/Modules/Tests/WordPressSharedTests/IntelligenceSuggestedTagsTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift @@ -142,7 +142,7 @@ struct IntelligenceSuggestedTagsTests { // MARK: - Test Cases -private struct TagTestCase: CustomTestStringConvertible { +struct TagTestCase: CustomTestStringConvertible { let title: String let post: String let siteTags: [String] diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceTestData.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift similarity index 100% rename from Modules/Tests/WordPressSharedTests/IntelligenceTestData.swift rename to Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift similarity index 100% rename from Modules/Tests/WordPressSharedTests/IntelligenceTestHelpers.swift rename to Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift diff --git a/Modules/Tests/WordPressSharedTests/IntelligenceUtilitiesTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceUtilitiesTests.swift similarity index 100% rename from Modules/Tests/WordPressSharedTests/IntelligenceUtilitiesTests.swift rename to Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceUtilitiesTests.swift From d2ade40f53d445e5b64d89fdfa82c2a05e747898 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 4 Dec 2025 10:13:51 -0500 Subject: [PATCH 08/13] Refactor test cases further --- .../IntelligenceExcerptGenerationTests.swift | 64 +-- .../IntelligencePostSummaryTests.swift | 49 +- .../IntelligenceSuggestedTagsTests.swift | 69 +-- .../Intelligence/IntelligenceTestData.swift | 456 ++++++++---------- .../IntelligenceTestHelpers.swift | 17 +- 5 files changed, 299 insertions(+), 356 deletions(-) diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift index 3b35910bbc0d..ddbec105bc53 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift @@ -50,7 +50,7 @@ struct IntelligenceExcerptGenerationTests { @Test(arguments: ExcerptTestCase.allCases) func excerptGeneration(testCase: ExcerptTestCase) async throws { let excerpts = try await generateExcerpts( - content: testCase.content, + content: testCase.testData.content, length: testCase.length, style: testCase.style ) @@ -58,22 +58,24 @@ struct IntelligenceExcerptGenerationTests { IntelligenceTestHelpers.printExcerptResults( testCase.title, excerpts: excerpts, - targetLength: testCase.targetLengthDescription, + targetLength: testCase.length.promptModifier, style: testCase.style.displayName ) } // MARK: - Comprehensive Tests + typealias Data = IntelligenceTestData + @available(iOS 26, *) @Test("All styles for Spanish content") func spanishAllStyles() async throws { - let content = IntelligenceTestData.spanishRecipePost + let testData = Data.spanishPost var rows: [[String]] = [] for style in IntelligenceService.ExcerptGeneration.Style.allCases { let excerpts = try await generateExcerpts( - content: content, + content: testData.content, length: .medium, style: style ) @@ -100,12 +102,12 @@ struct IntelligenceExcerptGenerationTests { @available(iOS 26, *) @Test("All lengths for English content") func englishAllLengths() async throws { - let content = IntelligenceTestData.englishTechPost + let testData = Data.englishTechPost var rows: [[String]] = [] for length in IntelligenceService.ExcerptGeneration.Length.allCases { let excerpts = try await generateExcerpts( - content: content, + content: testData.content, length: length, style: .engaging ) @@ -142,8 +144,9 @@ struct IntelligenceExcerptGenerationTests { @available(iOS 26, *) @Test("Spanish HTML content") func spanishHTMLContent() async throws { + let testData = Data.spanishPostWithHTML let excerpts = try await generateExcerpts( - content: IntelligenceTestData.spanishPostWithHTML, + content: testData.content, length: .medium, style: .engaging ) @@ -219,45 +222,28 @@ struct IntelligenceExcerptGenerationTests { // MARK: - Test Cases struct ExcerptTestCase: CustomTestStringConvertible { - let title: String - let content: String + let testData: TestContent let length: IntelligenceService.ExcerptGeneration.Length let style: IntelligenceService.ExcerptGeneration.Style - let language: String + + var title: String { + "\(testData.title) - \(length.displayName) \(style.displayName)" + } var testDescription: String { title } - var targetLengthDescription: String { - switch length { - case .short: "20-40 words" - case .medium: "50-70 words" - case .long: "120-180 words" - } - } + typealias Data = IntelligenceTestData static let allCases: [ExcerptTestCase] = [ - // Spanish - ExcerptTestCase(title: "Spanish - Short Engaging", content: IntelligenceTestData.spanishRecipePost, length: .short, style: .engaging, language: "Spanish"), - ExcerptTestCase(title: "Spanish - Medium Professional", content: IntelligenceTestData.spanishTechPost, length: .medium, style: .professional, language: "Spanish"), - ExcerptTestCase(title: "Spanish - Long Conversational", content: IntelligenceTestData.spanishTravelPost, length: .long, style: .conversational, language: "Spanish"), - // English - ExcerptTestCase(title: "English - Short Witty", content: IntelligenceTestData.englishTechPost, length: .short, style: .witty, language: "English"), - ExcerptTestCase(title: "English - Medium Formal", content: IntelligenceTestData.englishAcademicPost, length: .medium, style: .formal, language: "English"), - ExcerptTestCase(title: "English - Long Engaging", content: IntelligenceTestData.englishStoryPost, length: .long, style: .engaging, language: "English"), - - // French - ExcerptTestCase(title: "French - Medium Engaging", content: IntelligenceTestData.frenchCulturePost, length: .medium, style: .engaging, language: "French"), - ExcerptTestCase(title: "French - Short Professional", content: IntelligenceTestData.frenchBusinessPost, length: .short, style: .professional, language: "French"), - - // Japanese - ExcerptTestCase(title: "Japanese - Medium Conversational", content: IntelligenceTestData.japaneseCookingPost, length: .medium, style: .conversational, language: "Japanese"), - ExcerptTestCase(title: "Japanese - Short Formal", content: IntelligenceTestData.japaneseBusinessPost, length: .short, style: .formal, language: "Japanese"), - - // German - ExcerptTestCase(title: "German - Medium Professional", content: IntelligenceTestData.germanTechPost, length: .medium, style: .professional, language: "German"), + ExcerptTestCase(testData: Data.englishTechPost, length: .short, style: .witty), + ExcerptTestCase(testData: Data.englishAcademicPost, length: .medium, style: .formal), + ExcerptTestCase(testData: Data.englishStoryPost, length: .long, style: .engaging), + + // Other + ExcerptTestCase(testData: Data.spanishPost, length: .medium, style: .professional), + ExcerptTestCase(testData: Data.frenchPost, length: .short, style: .engaging), + ExcerptTestCase(testData: Data.japanesePost, length: .medium, style: .conversational), + ExcerptTestCase(testData: Data.germanTechPost, length: .short, style: .professional), ] } - -// MARK: - Test Data - diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift index a8dc00e73e71..f703c8519e19 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift @@ -38,12 +38,11 @@ struct IntelligencePostSummaryTests { @available(iOS 26, *) @Test(arguments: SummaryTestCase.basicCases) func postSummary(testCase: SummaryTestCase) async throws { - let summary = try await summarizePost(content: testCase.content) + let summary = try await summarizePost(content: testCase.testData.content) IntelligenceTestHelpers.printSummaryResults( - testCase.title, - summary: summary, - language: testCase.language + testCase.testData.title, + summary: summary ) #expect(!summary.isEmpty, "Summary should not be empty") @@ -51,15 +50,17 @@ struct IntelligencePostSummaryTests { // MARK: - Special Content Tests + typealias Data = IntelligenceTestData + @available(iOS 26, *) @Test("Summarize HTML-heavy Spanish post") func spanishHTMLContent() async throws { - let summary = try await summarizePost(content: IntelligenceTestData.spanishPostWithHTML) + let testData = Data.spanishPostWithHTML + let summary = try await summarizePost(content: testData.content) IntelligenceTestHelpers.printSummaryResults( - "Spanish HTML Content Summary", - summary: summary, - language: "Spanish" + testData.title, + summary: summary ) #expect(!summary.isEmpty, "Summary should not be empty") @@ -68,12 +69,12 @@ struct IntelligencePostSummaryTests { @available(iOS 26, *) @Test("Reader summary for Spanish article") func readerSummarySpanishContent() async throws { - let summary = try await summarizePost(content: IntelligenceTestData.spanishReaderArticle) + let testData = Data.spanishReaderArticle + let summary = try await summarizePost(content: testData.content) IntelligenceTestHelpers.printSummaryResults( - "Spanish Reader Article Summary", - summary: summary, - language: "Spanish" + testData.title, + summary: summary ) #expect(!summary.isEmpty, "Summary should not be empty") @@ -82,12 +83,12 @@ struct IntelligencePostSummaryTests { @available(iOS 26, *) @Test("Reader summary for English article") func readerSummaryEnglishContent() async throws { - let summary = try await summarizePost(content: IntelligenceTestData.englishReaderArticle) + let testData = Data.englishReaderArticle + let summary = try await summarizePost(content: testData.content) IntelligenceTestHelpers.printSummaryResults( - "English Reader Article Summary", - summary: summary, - language: "English" + testData.title, + summary: summary ) #expect(!summary.isEmpty, "Summary should not be empty") @@ -112,17 +113,17 @@ struct IntelligencePostSummaryTests { // MARK: - Test Cases struct SummaryTestCase: CustomTestStringConvertible { - let title: String - let content: String - let language: String + let testData: TestContent + + var testDescription: String { testData.title } - var testDescription: String { title } + typealias Data = IntelligenceTestData static let basicCases: [SummaryTestCase] = [ - SummaryTestCase(title: "Spanish Post Summary", content: IntelligenceTestData.spanishPost, language: "Spanish"), - SummaryTestCase(title: "English Post Summary", content: IntelligenceTestData.englishPost, language: "English"), - SummaryTestCase(title: "French Post Summary", content: IntelligenceTestData.frenchPost, language: "French"), - SummaryTestCase(title: "Japanese Post Summary", content: IntelligenceTestData.japanesePost, language: "Japanese"), + SummaryTestCase(testData: Data.spanishPost), + SummaryTestCase(testData: Data.englishPost), + SummaryTestCase(testData: Data.frenchPost), + SummaryTestCase(testData: Data.japanesePost), ] } diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift index 0a0621586b3d..afa4481b8b57 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift @@ -46,34 +46,35 @@ struct IntelligenceSuggestedTagsTests { @Test(arguments: TagTestCase.languageCombinations) func tagSuggestion(testCase: TagTestCase) async throws { let tags = try await suggestTags( - post: testCase.post, + post: testCase.testData.content, siteTags: testCase.siteTags, postTags: testCase.postTags ) IntelligenceTestHelpers.printTagResults( testCase.title, - tags: tags, - language: testCase.language + tags: tags ) } // MARK: - Edge Case Tests + typealias Data = IntelligenceTestData + @available(iOS 26, *) @Test("Existing post tags should be excluded") func excludeExistingTags() async throws { + let testData = Data.spanishPost let existingTags = ["recetas", "cocina"] let tags = try await suggestTags( - post: IntelligenceTestData.spanishPost, - siteTags: IntelligenceTestData.spanishSiteTags, + post: testData.content, + siteTags: Data.spanishSiteTags, postTags: existingTags ) IntelligenceTestHelpers.printTagResults( - "Exclude Existing Tags: \(existingTags.joined(separator: ", "))", - tags: tags, - language: "Spanish" + "\(testData.title) - Exclude Existing Tags: \(existingTags.joined(separator: ", "))", + tags: tags ) #expect(!tags.contains { existingTags.contains($0) }) @@ -82,16 +83,16 @@ struct IntelligenceSuggestedTagsTests { @available(iOS 26, *) @Test("Empty site tags") func emptySiteTags() async throws { + let testData = Data.spanishPost let tags = try await suggestTags( - post: IntelligenceTestData.spanishPost, + post: testData.content, siteTags: [], postTags: [] ) IntelligenceTestHelpers.printTagResults( - "No Site Tags Context", - tags: tags, - language: "Spanish" + "\(testData.title) - No Site Tags Context", + tags: tags ) } @@ -100,31 +101,30 @@ struct IntelligenceSuggestedTagsTests { func shortContent() async throws { let tags = try await suggestTags( post: "Deliciosa receta de gazpacho andaluz.", - siteTags: IntelligenceTestData.spanishSiteTags, + siteTags: Data.spanishSiteTags, postTags: [] ) IntelligenceTestHelpers.printTagResults( - "Very Short Content", - tags: tags, - language: "Spanish" + "Spanish - Very Short Content", + tags: tags ) } @available(iOS 26, *) @Test("Very long content") func longContent() async throws { - let longContent = String(repeating: IntelligenceTestData.spanishPost + "\n\n", count: 5) + let testData = Data.spanishPost + let longContent = String(repeating: testData.content + "\n\n", count: 5) let tags = try await suggestTags( post: longContent, - siteTags: IntelligenceTestData.spanishSiteTags, + siteTags: Data.spanishSiteTags, postTags: [] ) IntelligenceTestHelpers.printTagResults( - "Very Long Content (Truncated)", - tags: tags, - language: "Spanish" + "\(testData.title) - Very Long Content (Truncated)", + tags: tags ) } @@ -143,21 +143,30 @@ struct IntelligenceSuggestedTagsTests { // MARK: - Test Cases struct TagTestCase: CustomTestStringConvertible { - let title: String - let post: String + let testData: TestContent let siteTags: [String] let postTags: [String] - let language: String + let siteTagsLanguage: String? + + var title: String { + if let siteTagsLanguage = siteTagsLanguage { + return "\(testData.title) → \(siteTagsLanguage) Site Tags" + } else { + return testData.title + } + } var testDescription: String { title } + typealias Data = IntelligenceTestData + static let languageCombinations: [TagTestCase] = [ - TagTestCase(title: "Spanish Content → Spanish Site Tags", post: IntelligenceTestData.spanishPost, siteTags: IntelligenceTestData.spanishSiteTags, postTags: [], language: "Spanish"), - TagTestCase(title: "Spanish Content → English Site Tags", post: IntelligenceTestData.spanishPost, siteTags: IntelligenceTestData.englishSiteTags, postTags: [], language: "English"), - TagTestCase(title: "English Content → Spanish Site Tags", post: IntelligenceTestData.englishPost, siteTags: IntelligenceTestData.spanishSiteTags, postTags: [], language: "Spanish"), - TagTestCase(title: "Mixed Language Content", post: IntelligenceTestData.mixedLanguagePost, siteTags: IntelligenceTestData.englishSiteTags, postTags: [], language: "Dominant language"), - TagTestCase(title: "French Content → French Site Tags", post: IntelligenceTestData.frenchPost, siteTags: IntelligenceTestData.frenchSiteTags, postTags: [], language: "French"), - TagTestCase(title: "Japanese Content → Japanese Site Tags", post: IntelligenceTestData.japanesePost, siteTags: IntelligenceTestData.japaneseSiteTags, postTags: [], language: "Japanese"), + TagTestCase(testData: Data.spanishPost, siteTags: Data.spanishSiteTags, postTags: [], siteTagsLanguage: "Spanish"), + TagTestCase(testData: Data.spanishPost, siteTags: Data.englishSiteTags, postTags: [], siteTagsLanguage: "English"), + TagTestCase(testData: Data.englishPost, siteTags: Data.spanishSiteTags, postTags: [], siteTagsLanguage: "Spanish"), + TagTestCase(testData: Data.mixedLanguagePost, siteTags: Data.englishSiteTags, postTags: [], siteTagsLanguage: "Dominant language"), + TagTestCase(testData: Data.frenchPost, siteTags: Data.frenchSiteTags, postTags: [], siteTagsLanguage: "French"), + TagTestCase(testData: Data.japanesePost, siteTags: Data.japaneseSiteTags, postTags: [], siteTagsLanguage: "Japanese"), ] } diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift index d7043e01a50b..c4df11472046 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift @@ -1,5 +1,11 @@ import Foundation +/// Test content with title and body for intelligence service tests. +struct TestContent { + let title: String + let content: String +} + /// Shared test data for intelligence service tests. /// /// This enum provides sample content in multiple languages for testing @@ -7,281 +13,221 @@ import Foundation enum IntelligenceTestData { // MARK: - Spanish Content - static let spanishRecipePost = """ - La auténtica tortilla española es mucho más que simplemente huevos y patatas. Es un arte - culinario que requiere paciencia, técnica y los ingredientes adecuados. La clave está en - lograr el equilibrio perfecto: patatas tiernas pero no deshechas, huevos jugosos en el centro, - y esa capa exterior ligeramente dorada que aporta textura. - - El secreto mejor guardado de las abuelas españolas es la temperatura del aceite. Debe estar - lo suficientemente caliente para cocinar las patatas, pero no tanto como para dorarlas rápidamente. - Este proceso lento permite que las patatas se impregnen del aceite de oliva, creando esa textura - cremosa característica. - - Algunos puristas insisten en que solo debe llevar patatas, huevos, cebolla y sal. Otros - defienden versiones más innovadoras con chorizo, pimientos o incluso espinacas. Sin embargo, - la receta tradicional valenciana ha resistido el paso del tiempo por una razón: su simplicidad - permite que brillen los sabores fundamentales. - """ - - static let spanishTechPost = """ - La inteligencia artificial generativa ha revolucionado la forma en que interactuamos con la - tecnología. Modelos de lenguaje como GPT-4 y Claude han demostrado capacidades sorprendentes - en tareas que van desde la redacción creativa hasta el análisis de código complejo. - - Sin embargo, esta transformación digital plantea importantes cuestiones éticas. ¿Cómo garantizamos - que estos sistemas sean justos e imparciales? ¿Qué medidas debemos implementar para proteger - la privacidad de los datos? ¿Cómo equilibramos la automatización con la preservación del empleo? - - Los expertos coinciden en que necesitamos un marco regulatorio robusto que fomente la innovación - mientras protege los derechos fundamentales. La Unión Europea ha dado un paso importante con su - Ley de IA, estableciendo un precedente para otras regiones del mundo. - """ - - static let spanishTravelPost = """ - Sevilla en primavera es una experiencia sensorial incomparable. Las calles estrechas del barrio - de Santa Cruz se llenan del aroma de azahar, mientras el sonido de las guitarras flamencas - resuena desde los patios escondidos. Durante la Feria de Abril, la ciudad se transforma en un - espectáculo de color, música y tradición que deja sin aliento a los visitantes. - - Pasear por el Real Alcázar al atardecer es como retroceder en el tiempo. Los jardines moriscos, - con sus fuentes burbujeantes y naranjos centenarios, ofrecen un oasis de tranquilidad en medio - del bullicio urbano. La mezcla de arquitectura mudéjar, gótica y renacentista cuenta la rica - historia de una ciudad que ha sido encrucijada de culturas durante milenios. - - No se puede visitar Sevilla sin probar las tapas auténticas. Olvidaos de los lugares turísticos - y aventuraos en los bares de barrio donde los locales se congregan. Un montadito de pringá, unas - espinacas con garbanzos, o el clásico salmorejo cordobés acompañados de un fino bien frío son - la verdadera esencia de la cultura sevillana. - """ - - static let spanishPostWithHTML = """ - -

Receta Auténtica de Paella Valenciana

- - - -

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

- - - -

Ingredientes Principales

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

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

- - """ - - static let spanishPost = """ - La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. - Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad - de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. - - La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. - El azafrán no solo aporta ese característico color dorado, sino también un sabor único - e inconfundible. - - Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la - variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para - conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. - """ - - static let spanishReaderArticle = """ - 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. - """ + static let spanishPostWithHTML = TestContent( + title: "Spanish Post with HTML", + content: """ + +

Receta Auténtica de Paella Valenciana

+ + + +

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

+ + + +

Ingredientes Principales

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

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

+ + """ + ) + + static let spanishPost = TestContent( + title: "Spanish Post", + content: """ + La paella valenciana es uno de los platos más emblemáticos de la gastronomía española. + Originaria de Valencia, esta receta tradicional combina arroz, azafrán, y una variedad + de ingredientes que pueden incluir pollo, conejo, judías verdes, y garrofón. + + La clave para una paella perfecta está en el sofrito inicial y en el punto exacto del arroz. + El azafrán no solo aporta ese característico color dorado, sino también un sabor único + e inconfundible. + + Es importante utilizar un buen caldo casero y arroz de calidad, preferiblemente de la + variedad bomba o senia. El fuego debe ser fuerte al principio y suave al final para + conseguir el socarrat, esa capa crujiente de arroz que se forma en el fondo de la paellera. + """ + ) + + static let spanishReaderArticle = TestContent( + title: "Spanish Reader Article", + content: """ + El cambio climático está afectando de manera significativa a los ecosistemas marinos + del Mediterráneo. Científicos del CSIC han documentado un aumento de 2 grados en la + temperatura media del agua durante los últimos 30 años, lo que ha provocado cambios + en las rutas migratorias de varias especies de peces y la proliferación de especies + invasoras procedentes de aguas más cálidas. + """ + ) // MARK: - English Content - static let englishTechPost = """ - Quantum computing represents a paradigm shift in how we approach computational problems. Unlike - classical computers that use bits (0s and 1s), quantum computers leverage qubits that can exist - in superposition, simultaneously representing multiple states. - - This fundamental difference enables quantum computers to tackle problems that are intractable - for classical machines. Drug discovery, cryptography, optimization, and climate modeling are - just a few domains poised for revolutionary breakthroughs. - - However, significant challenges remain. Quantum systems are incredibly fragile, requiring - near-absolute-zero temperatures and isolation from environmental interference. Error correction - is another major hurdle, as quantum states are prone to decoherence. - """ - - static let englishAcademicPost = """ - The phenomenon of linguistic relativity, often referred to as the Sapir-Whorf hypothesis, - posits that the structure of a language influences its speakers' worldview and cognition. - While the strong version of this hypothesis has been largely discredited, contemporary research - suggests more nuanced relationships between language and thought. - - Recent studies in cognitive linguistics have demonstrated that language can indeed affect - perception and categorization, particularly in domains like color perception, spatial reasoning, - and temporal cognition. However, these effects are context-dependent and vary significantly - across different cognitive domains. - - Cross-linguistic research continues to provide valuable insights into the universal and - language-specific aspects of human cognition, challenging researchers to refine their - theoretical frameworks and methodological approaches. - """ - - static let englishStoryPost = """ - The old lighthouse keeper had seen many storms in his forty years tending the beacon, but - none quite like the tempest that rolled in that October evening. Dark clouds gathered on - the horizon like an invading army, their edges tinged with an unsettling green hue. - - As the first drops of rain pelted the lighthouse windows, Magnus checked the lamp one final - time. The beam cut through the gathering darkness, a lifeline for any vessels brave or foolish - enough to be out on such a night. He'd heard the coastguard warnings on the radio—winds - exceeding 90 miles per hour, waves reaching heights of 30 feet. - - Down in the keeper's quarters, Magnus brewed strong coffee and settled into his worn leather - chair. Outside, the wind howled like a wounded beast, but within these thick stone walls, - he felt safe. This lighthouse had withstood two centuries of nature's fury; it would stand - through one more night. - """ - - static let englishPost = """ - Sourdough bread has experienced a remarkable revival in recent years, with home bakers - around the world rediscovering this ancient craft. The natural fermentation process - creates a distinctive tangy flavor and numerous health benefits. - - The key to successful sourdough lies in maintaining a healthy starter culture. This - living mixture of flour and water harbors wild yeast and beneficial bacteria that - work together to leaven the bread and develop complex flavors. - - Temperature and timing are crucial factors. The fermentation process can take anywhere - from 12 to 24 hours, depending on ambient temperature and the activity of your starter. - """ - - static let englishReaderArticle = """ - 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. - """ + static let englishTechPost = TestContent( + title: "English Tech Post", + content: """ + Quantum computing represents a paradigm shift in how we approach computational problems. Unlike + classical computers that use bits (0s and 1s), quantum computers leverage qubits that can exist + in superposition, simultaneously representing multiple states. + + This fundamental difference enables quantum computers to tackle problems that are intractable + for classical machines. Drug discovery, cryptography, optimization, and climate modeling are + just a few domains poised for revolutionary breakthroughs. + + However, significant challenges remain. Quantum systems are incredibly fragile, requiring + near-absolute-zero temperatures and isolation from environmental interference. Error correction + is another major hurdle, as quantum states are prone to decoherence. + """ + ) + + static let englishAcademicPost = TestContent( + title: "English Academic Post", + content: """ + The phenomenon of linguistic relativity, often referred to as the Sapir-Whorf hypothesis, + posits that the structure of a language influences its speakers' worldview and cognition. + While the strong version of this hypothesis has been largely discredited, contemporary research + suggests more nuanced relationships between language and thought. + + Recent studies in cognitive linguistics have demonstrated that language can indeed affect + perception and categorization, particularly in domains like color perception, spatial reasoning, + and temporal cognition. However, these effects are context-dependent and vary significantly + across different cognitive domains. + + Cross-linguistic research continues to provide valuable insights into the universal and + language-specific aspects of human cognition, challenging researchers to refine their + theoretical frameworks and methodological approaches. + """ + ) + + static let englishStoryPost = TestContent( + title: "English Story Post", + content: """ + The old lighthouse keeper had seen many storms in his forty years tending the beacon, but + none quite like the tempest that rolled in that October evening. Dark clouds gathered on + the horizon like an invading army, their edges tinged with an unsettling green hue. + + As the first drops of rain pelted the lighthouse windows, Magnus checked the lamp one final + time. The beam cut through the gathering darkness, a lifeline for any vessels brave or foolish + enough to be out on such a night. He'd heard the coastguard warnings on the radio—winds + exceeding 90 miles per hour, waves reaching heights of 30 feet. + + Down in the keeper's quarters, Magnus brewed strong coffee and settled into his worn leather + chair. Outside, the wind howled like a wounded beast, but within these thick stone walls, + he felt safe. This lighthouse had withstood two centuries of nature's fury; it would stand + through one more night. + """ + ) + + static let englishPost = TestContent( + title: "English Post", + content: """ + Sourdough bread has experienced a remarkable revival in recent years, with home bakers + around the world rediscovering this ancient craft. The natural fermentation process + creates a distinctive tangy flavor and numerous health benefits. + + The key to successful sourdough lies in maintaining a healthy starter culture. This + living mixture of flour and water harbors wild yeast and beneficial bacteria that + work together to leaven the bread and develop complex flavors. + + Temperature and timing are crucial factors. The fermentation process can take anywhere + from 12 to 24 hours, depending on ambient temperature and the activity of your starter. + """ + ) + + static let englishReaderArticle = TestContent( + title: "English Reader Article", + content: """ + Recent advances in quantum computing have brought us closer to solving complex problems + that are impossible for classical computers. Google's quantum processor achieved + quantum supremacy by performing a calculation in 200 seconds that would take the world's + fastest supercomputer 10,000 years to complete. However, practical applications for + everyday computing are still years away. + """ + ) // MARK: - French Content - static let frenchCulturePost = """ - Le café français est bien plus qu'une simple boisson; c'est une institution culturelle qui - incarne l'art de vivre à la française. Depuis les cafés littéraires de Saint-Germain-des-Prés - fréquentés par Sartre et de Beauvoir jusqu'aux bistrots de quartier où les habitués se - retrouvent chaque matin, ces établissements sont le cœur battant de la vie sociale française. - - L'architecture des cafés parisiens, avec leurs terrasses caractéristiques et leurs décors - Art nouveau ou Art déco, raconte l'histoire de la ville. Chaque café a son caractère unique, - son ambiance particulière, et ses réguliers qui y viennent non seulement pour le café, - mais surtout pour la conversation, l'observation, et cette délicieuse pause dans le rythme - effréné de la vie moderne. - """ - - static let frenchBusinessPost = """ - La transformation numérique des entreprises françaises s'accélère, portée par la nécessité - de rester compétitives dans un marché mondialisé. Les PME, longtemps réticentes à adopter - de nouvelles technologies, reconnaissent désormais l'importance cruciale de l'innovation - digitale pour leur survie et leur croissance. - - Les investissements dans l'intelligence artificielle, le cloud computing, et la cybersécurité - augmentent exponentiellement. Le gouvernement français soutient cette transition avec des - programmes d'accompagnement et des incitations fiscales, conscient que la compétitivité - future du pays en dépend. - """ - - static let frenchPost = """ - La cuisine française est reconnue mondialement pour sa finesse et sa diversité. - Du coq au vin bourguignon au délicieux cassoulet du Sud-Ouest, chaque région possède - ses spécialités qui racontent une histoire culinaire unique. - - Les techniques de base de la cuisine française, comme le mirepoix, le roux, et les - cinq sauces mères, constituent le fondement de nombreuses préparations classiques. - Ces méthodes transmises de génération en génération permettent de créer des plats - d'une grande complexité et raffinement. - - L'utilisation d'ingrédients frais et de saison est primordiale. Les marchés locaux - offrent une abondance de produits qui inspirent les chefs et les cuisiniers amateurs. - """ - - // MARK: - Japanese Content - - static let japaneseCookingPost = """ - 日本料理における包丁の技術は、単なる食材の切断以上の意味を持ちます。それは料理人の - 心と技が一体となる瞬間であり、何年もの修行を通じて磨かれる芸術形態です。 - - 寿司職人が魚を捌く際の手つきには、無駄な動きが一切ありません。一つ一つの動作が計算され、 - 食材の繊維を理解し、その特性を最大限に引き出すための最適な方法が選ばれています。 - この技術は「包丁さばき」と呼ばれ、日本料理の根幹を成すものです。 + 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. - static let japaneseBusinessPost = """ - 日本企業のグローバル展開において、文化的な適応能力が成功の鍵となっています。 - 従来の日本的経営手法を維持しながら、現地の商習慣や消費者ニーズに柔軟に対応する - バランス感覚が求められています。 + L'utilisation d'ingrédients frais et de saison est primordiale. Les marchés locaux + offrent une abondance de produits qui inspirent les chefs et les cuisiniers amateurs. + """ + ) - 特に人材マネジメントの分野では、年功序列や終身雇用といった伝統的なシステムと、 - 成果主義やダイバーシティといった現代的な価値観の融合が課題となっています。 - 多くの企業が試行錯誤を重ねながら、新しい時代に適した経営モデルを模索しています。 - """ + // MARK: - Japanese Content - static let japanesePost = """ - 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 - この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 + static let japanesePost = TestContent( + title: "Japanese Post", + content: """ + 日本料理の基本である出汁は、昆布と鰹節から作られる伝統的な調味料です。 + この旨味の素は、味噌汁、煮物、そして様々な料理の基礎となっています。 - 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 - 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 + 正しい出汁の取り方は、まず昆布を水に浸して弱火でゆっくりと加熱します。 + 沸騰直前に昆布を取り出し、その後鰹節を加えて数分間煮出します。 - 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 - インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 - """ + 良質な出汁を使うことで、料理全体の味わいが格段に向上します。 + インスタント出汁も便利ですが、本格的な料理には手作りの出汁が欠かせません。 + """ + ) // MARK: - German Content - static let germanTechPost = """ - 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. - """ + static let germanTechPost = TestContent( + title: "German Tech Post", + content: """ + Die deutsche Automobilindustrie steht vor einem beispiellosen Wandel. Der Übergang von + Verbrennungsmotoren zu Elektroantrieben erfordert nicht nur technologische Innovation, + sondern auch eine grundlegende Neuausrichtung der gesamten Wertschöpfungskette. + + Traditionelle Zulieferer müssen sich anpassen oder riskieren, obsolet zu werden. Gleichzeitig + entstehen neue Geschäftsmodelle rund um Batterietechnologie, Ladeinfrastruktur und + Software-definierte Fahrzeuge. Die Frage ist nicht mehr, ob dieser Wandel kommt, sondern + wie schnell deutsche Unternehmen sich anpassen können, um ihre führende Position in der + globalen Automobilbranche zu behalten. + """ + ) // MARK: - Mixed Language Content - static let mixedLanguagePost = """ - The Mediterranean Diet: Una Guía Completa + 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. + 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. - """ + Los beneficios para la salud son numerosos: reduced risk of heart disease, mejor + control del peso, y longevidad aumentada. Studies have shown that people who follow + this diet tend to live longer and healthier lives. + """ + ) // MARK: - Tag Data diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift index ffb7e8649dde..110bf6baee5a 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift @@ -7,10 +7,9 @@ enum IntelligenceTestHelpers { static func printTagResults( _ title: String, - tags: [String], - language: String + tags: [String] ) { - printSectionHeader(title, language: language) + printSectionHeader(title) print("📑 Generated \(tags.count) tags:") print() @@ -25,10 +24,9 @@ enum IntelligenceTestHelpers { static func printSummaryResults( _ title: String, - summary: String, - language: String + summary: String ) { - printSectionHeader(title, language: language) + printSectionHeader(title) let wordCount = summary.split(separator: " ").count let charCount = summary.count @@ -127,10 +125,12 @@ enum IntelligenceTestHelpers { // MARK: - Utilities - private static func printSectionHeader(_ title: String, language: String? = nil) { + private static func printSectionHeader(_ title: String) { print() print("╔" + String(repeating: "═", count: 78) + "╗") + // Extract language from title (first word) + let language = title.split(separator: " ").first.map(String.init) if let language = language { let flag = languageFlag(for: language) print("║ \(flag) \(title)") @@ -154,7 +154,8 @@ enum IntelligenceTestHelpers { case "french": return "🇫🇷" case "japanese": return "🇯🇵" case "german": return "🇩🇪" - case "dominant language": return "🌐" + case "mixed": return "🌐" + case "dominant": return "🌐" default: return "🌍" } } From f672e4fad0155b7eb86076ede80a6358a567f17b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 4 Dec 2025 14:29:41 -0500 Subject: [PATCH 09/13] Move Length --- .../Intelligence/IntelligenceService.swift | 40 ++++++++++++++++++- .../UseCases/ExcerptGeneration.swift | 39 +----------------- .../IntelligenceExcerptGenerationTests.swift | 6 +-- .../IntelligenceSuggestedTagsTests.swift | 6 +-- 4 files changed, 44 insertions(+), 47 deletions(-) diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift index f18c51403725..30e93f386edb 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift @@ -72,7 +72,7 @@ public actor IntelligenceService { // MARK: - Shared Parameters - /// Writing style for generated excerpts. + /// Writing style for generated text. public enum WritingStyle: String, CaseIterable, Sendable { case engaging case conversational @@ -109,4 +109,42 @@ public actor IntelligenceService { } } } + + /// Target length for generated text. + public enum WritingLength: Int, CaseIterable, Sendable { + 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 { + switch self { + case .short: "short" + case .medium: "medium" + case .long: "long" + } + } + + public var promptModifier: String { + "\(wordRange) words" + } + + private var wordRange: String { + switch self { + case .short: "20-40" + case .medium: "50-70" + case .long: "120-180" + } + } + } } diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift index b384ce5bbf06..eb48338cc7a2 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift @@ -9,44 +9,7 @@ extension IntelligenceService { /// and one-shot generation (for tests and background tasks). public enum ExcerptGeneration { public typealias Style = IntelligenceService.WritingStyle - - /// Target length for generated excerpts. - public enum Length: Int, CaseIterable, Sendable { - 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 { - switch self { - case .short: "short" - case .medium: "medium" - case .long: "long" - } - } - - public var promptModifier: String { - "\(wordRange) words" - } - - private var wordRange: String { - switch self { - case .short: "20-40" - case .medium: "50-70" - case .long: "120-180" - } - } - } + public typealias Length = IntelligenceService.WritingLength // MARK: Building Blocks (for UI with session continuity) diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift index ddbec105bc53..a14a30344e6b 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift @@ -56,7 +56,7 @@ struct IntelligenceExcerptGenerationTests { ) IntelligenceTestHelpers.printExcerptResults( - testCase.title, + testCase.testDescription, excerpts: excerpts, targetLength: testCase.length.promptModifier, style: testCase.style.displayName @@ -226,12 +226,10 @@ struct ExcerptTestCase: CustomTestStringConvertible { let length: IntelligenceService.ExcerptGeneration.Length let style: IntelligenceService.ExcerptGeneration.Style - var title: String { + var testDescription: String { "\(testData.title) - \(length.displayName) \(style.displayName)" } - var testDescription: String { title } - typealias Data = IntelligenceTestData static let allCases: [ExcerptTestCase] = [ diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift index afa4481b8b57..b03082cfe1fb 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift @@ -52,7 +52,7 @@ struct IntelligenceSuggestedTagsTests { ) IntelligenceTestHelpers.printTagResults( - testCase.title, + testCase.testDescription, tags: tags ) } @@ -148,7 +148,7 @@ struct TagTestCase: CustomTestStringConvertible { let postTags: [String] let siteTagsLanguage: String? - var title: String { + var testDescription: String { if let siteTagsLanguage = siteTagsLanguage { return "\(testData.title) → \(siteTagsLanguage) Site Tags" } else { @@ -156,8 +156,6 @@ struct TagTestCase: CustomTestStringConvertible { } } - var testDescription: String { title } - typealias Data = IntelligenceTestData static let languageCombinations: [TagTestCase] = [ From c89b9b7c7b394b6a9f46878da3251c42281f51f2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 4 Dec 2025 14:37:11 -0500 Subject: [PATCH 10/13] Update usage --- .../Intelligence/IntelligenceService.swift | 4 ++- .../Intelligence/LanguageModelHelper.swift | 34 ------------------- .../Excerpt/PostSettingsExcerptEditor.swift | 2 +- .../PostSettingsGenerateExcerptView.swift | 17 +++++----- 4 files changed, 13 insertions(+), 44 deletions(-) delete mode 100644 Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift index 30e93f386edb..0cfdc01ffae9 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift @@ -18,7 +18,9 @@ public actor IntelligenceService { /// Checks if intelligence features are supported on the current device. public nonisolated static var isSupported: Bool { - guard #available(iOS 26, *) else { return false } + guard #available(iOS 26, *) else { + return false + } switch SystemLanguageModel.default.availability { case .available: return true diff --git a/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift b/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift deleted file mode 100644 index b44b7fdcce2f..000000000000 --- a/Modules/Sources/WordPressShared/Intelligence/LanguageModelHelper.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import FoundationModels - -// MARK: - Backward Compatibility Type Aliases -// These will be removed once all usages are migrated to IntelligenceService nested types - -@available(iOS 26, *) -public typealias GenerationStyle = IntelligenceService.WritingStyle - -@available(iOS 26, *) -public typealias GeneratedContentLength = IntelligenceService.ExcerptGeneration.Length - -@available(iOS 26, *) -public enum LanguageModelHelper { - public static var isSupported: Bool { - IntelligenceService.isSupported - } - - public static var generateExcerptInstructions: String { - IntelligenceService.ExcerptGeneration.instructions - } - - public static func makeGenerateExcerptPrompt( - content: String, - length: GeneratedContentLength, - style: GenerationStyle - ) -> String { - IntelligenceService.ExcerptGeneration.makePrompt(content: content, length: length, style: style) - } - - public static var generateMoreOptionsPrompt: String { - IntelligenceService.ExcerptGeneration.loadMorePrompt - } -} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift index 668818775e53..9c2b42bd1c03 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsExcerptEditor.swift @@ -42,7 +42,7 @@ struct PostSettingsExcerptEditor: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - if FeatureFlag.intelligence.enabled && !postContent.isEmpty && LanguageModelHelper.isSupported { + if FeatureFlag.intelligence.enabled && !postContent.isEmpty && IntelligenceService.isSupported { if #available(iOS 26, *) { PostSettingsGenerateExcerptButton( content: postContent, diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift index a5f6f72b2be4..341f5a053ce9 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift @@ -2,6 +2,7 @@ import SwiftUI import WordPressUI import DesignSystem import FoundationModels +import WordPressShared @available(iOS 26, *) struct PostSettingsGenerateExcerptView: View { @@ -11,10 +12,10 @@ struct PostSettingsGenerateExcerptView: View { @Environment(\.dismiss) private var dismiss @AppStorage("jetpack_ai_generated_excerpt_style") - private var style: GenerationStyle = .engaging + private var style: IntelligenceService.ExcerptGeneration.Style = .engaging @AppStorage("jetpack_ai_generated_excerpt_length") - private var length: GeneratedContentLength = .medium + private var length: IntelligenceService.ExcerptGeneration.Length = .medium @State private var results: [ExcerptGenerationResult.PartiallyGenerated] = [] @State private var isGenerating = false @@ -162,9 +163,9 @@ struct PostSettingsGenerateExcerptView: View { Slider( value: Binding( get: { Double(length.rawValue) }, - set: { length = GeneratedContentLength(rawValue: Int($0)) ?? .medium } + set: { length = IntelligenceService.ExcerptGeneration.Length(rawValue: Int($0)) ?? .medium } ), - in: 0...Double(GeneratedContentLength.allCases.count - 1), + in: 0...Double(IntelligenceService.ExcerptGeneration.Length.allCases.count - 1), step: 1 ) { Text(Strings.lengthSliderAccessibilityLabel) @@ -199,7 +200,7 @@ struct PostSettingsGenerateExcerptView: View { Spacer(minLength: 8) Picker(Strings.stylePickerAccessibilityLabel, selection: $style) { - ForEach(GenerationStyle.allCases, id: \.self) { style in + ForEach(IntelligenceService.ExcerptGeneration.Style.allCases, id: \.self) { style in Text(style.displayName) .tag(style) } @@ -232,7 +233,7 @@ struct PostSettingsGenerateExcerptView: View { do { let session = LanguageModelSession( model: .init(guardrails: .permissiveContentTransformations), - instructions: LanguageModelHelper.generateExcerptInstructions + instructions: IntelligenceService.ExcerptGeneration.instructions ) self.session = session try await actuallyGenerateExcerpts(in: session) @@ -274,7 +275,7 @@ struct PostSettingsGenerateExcerptView: View { } let content = IntelligenceService().extractRelevantText(from: postContent) - let prompt = isLoadMore ? LanguageModelHelper.generateMoreOptionsPrompt : LanguageModelHelper.makeGenerateExcerptPrompt(content: content, length: length, style: style) + let prompt = isLoadMore ? IntelligenceService.ExcerptGeneration.loadMorePrompt : IntelligenceService.ExcerptGeneration.makePrompt(content: content, length: length, style: style) let stream = session.streamResponse(to: prompt, generating: ExcerptGenerationResult.self) for try await result in stream { @@ -299,7 +300,7 @@ struct PostSettingsGenerateExcerptView: View { WPAnalytics.track(.intelligenceExcerptOptionsGenerated, properties: [ "length": length.trackingName, "style": style.rawValue, - "load_more": isLoadMore ? 1 : 0 + "load_more": isLoadMore ]) } } From 0e92c185af145ff66f24c9cd5cc32b32441d8c1d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 4 Dec 2025 14:41:30 -0500 Subject: [PATCH 11/13] Extract makeSession() and make ExcerptGeneration a struct --- .../UseCases/ExcerptGeneration.swift | 72 +++++++++++-------- .../IntelligenceExcerptGenerationTests.swift | 7 +- .../PostSettingsGenerateExcerptView.swift | 9 ++- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift index eb48338cc7a2..14161b228130 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift +++ b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift @@ -7,11 +7,51 @@ extension IntelligenceService { /// 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). - public enum ExcerptGeneration { + @available(iOS 26, *) + public struct ExcerptGeneration { + public let length: Length + public let style: Style + public var options: GenerationOptions + + public init(length: Length, style: Style, options: GenerationOptions = GenerationOptions(temperature: 0.7)) { + self.length = length + self.style = style + self.options = options + } + public typealias Style = IntelligenceService.WritingStyle public typealias Length = IntelligenceService.WritingLength - // MARK: Building Blocks (for UI with session continuity) + // MARK: - Instance Methods + + /// Creates a language model session configured for excerpt generation. + public func makeSession() -> LanguageModelSession { + LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: Self.instructions + ) + } + + /// Creates a prompt for this excerpt configuration. + public func makePrompt(content: String) -> String { + Self.makePrompt(content: content, length: length, style: style) + } + + /// Generates excerpts with this configuration. + public func generate(content: String) async throws -> [String] { + let extractedContent = IntelligenceService.extractRelevantText(from: content) + let session = makeSession() + + let response = try await session.respond( + to: makePrompt(content: extractedContent), + generating: Result.self, + options: options + ) + + return response.content.excerpts + } + + // MARK: - Building Blocks (for UI with session continuity) /// Instructions for the language model session. public static var instructions: String { @@ -59,34 +99,8 @@ extension IntelligenceService { "Generate additional three options" } - // MARK: Convenience Method (for simple one-shot usage) - - /// Generates excerpts in a single call (for tests and simple usage). - public static func generate( - content: String, - length: Length, - style: Style - ) async throws -> [String] { - guard #available(iOS 26, *) else { - throw URLError(.unknown) - } - let extractedContent = IntelligenceService.extractRelevantText(from: content) - - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: instructions - ) - - let response = try await session.respond( - to: makePrompt(content: extractedContent, length: length, style: style), - generating: Result.self, - options: GenerationOptions(temperature: 0.7) - ) - - return response.content.excerpts - } + // MARK: - Result Type - @available(iOS 26, *) @Generable public struct Result { @Guide(description: "Three different excerpt variations") diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift index a14a30344e6b..72d6aed18ce6 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift @@ -211,11 +211,8 @@ struct IntelligenceExcerptGenerationTests { length: IntelligenceService.ExcerptGeneration.Length, style: IntelligenceService.ExcerptGeneration.Style ) async throws -> [String] { - try await IntelligenceService.ExcerptGeneration.generate( - content: content, - length: length, - style: style - ) + let generator = IntelligenceService.ExcerptGeneration(length: length, style: style) + return try await generator.generate(content: content) } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift index 341f5a053ce9..3673f5e09aa6 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/Views/Excerpt/PostSettingsGenerateExcerptView.swift @@ -231,10 +231,8 @@ struct PostSettingsGenerateExcerptView: View { generationTask = Task { do { - let session = LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: IntelligenceService.ExcerptGeneration.instructions - ) + let generator = IntelligenceService.ExcerptGeneration(length: length, style: style) + let session = generator.makeSession() self.session = session try await actuallyGenerateExcerpts(in: session) } catch { @@ -274,8 +272,9 @@ struct PostSettingsGenerateExcerptView: View { isGenerating = false } + let generator = IntelligenceService.ExcerptGeneration(length: length, style: style) let content = IntelligenceService().extractRelevantText(from: postContent) - let prompt = isLoadMore ? IntelligenceService.ExcerptGeneration.loadMorePrompt : IntelligenceService.ExcerptGeneration.makePrompt(content: content, length: length, style: style) + let prompt = isLoadMore ? IntelligenceService.ExcerptGeneration.loadMorePrompt : generator.makePrompt(content: content) let stream = session.streamResponse(to: prompt, generating: ExcerptGenerationResult.self) for try await result in stream { From 7b4abe22f4e7a5ac92b39d8db913f42bd410c17f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 4 Dec 2025 16:24:14 -0500 Subject: [PATCH 12/13] Create WordPressIntelligence module --- Modules/Package.swift | 6 + .../IntelligenceService.swift | 78 ------------- .../IntelligenceUtilities.swift | 0 .../Parameters/ContentLength.swift | 40 +++++++ .../Parameters/WritingStyle.swift | 40 +++++++ .../UseCases/ExcerptGeneration.swift | 100 ++++++++++++++++ .../UseCases/PostSummary.swift | 0 .../UseCases/SupportTicketSummary.swift | 0 .../UseCases/TagSuggestion.swift | 1 + .../UseCases/ExcerptGeneration.swift | 110 ------------------ .../ExcerptGenerationTests.swift} | 3 +- .../IntelligencePostSummaryTests.swift | 2 +- .../IntelligenceServiceTests.swift | 2 +- .../IntelligenceSuggestedTagsTests.swift | 2 +- .../IntelligenceTestData.swift | 0 .../IntelligenceTestHelpers.swift | 0 .../IntelligenceUtilitiesTests.swift | 2 +- 17 files changed, 192 insertions(+), 194 deletions(-) rename Modules/Sources/{WordPressShared/Intelligence => WordPressIntelligence}/IntelligenceService.swift (52%) rename Modules/Sources/{WordPressShared/Intelligence => WordPressIntelligence}/IntelligenceUtilities.swift (100%) create mode 100644 Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift create mode 100644 Modules/Sources/WordPressIntelligence/Parameters/WritingStyle.swift create mode 100644 Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift rename Modules/Sources/{WordPressShared/Intelligence => WordPressIntelligence}/UseCases/PostSummary.swift (100%) rename Modules/Sources/{WordPressShared/Intelligence => WordPressIntelligence}/UseCases/SupportTicketSummary.swift (100%) rename Modules/Sources/{WordPressShared/Intelligence => WordPressIntelligence}/UseCases/TagSuggestion.swift (99%) delete mode 100644 Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift rename Modules/Tests/{WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift => WordPressIntelligenceTests/ExcerptGenerationTests.swift} (98%) rename Modules/Tests/{WordPressSharedTests/Intelligence => WordPressIntelligenceTests}/IntelligencePostSummaryTests.swift (99%) rename Modules/Tests/{WordPressSharedTests/Intelligence => WordPressIntelligenceTests}/IntelligenceServiceTests.swift (90%) rename Modules/Tests/{WordPressSharedTests/Intelligence => WordPressIntelligenceTests}/IntelligenceSuggestedTagsTests.swift (99%) rename Modules/Tests/{WordPressSharedTests/Intelligence => WordPressIntelligenceTests}/IntelligenceTestData.swift (100%) rename Modules/Tests/{WordPressSharedTests/Intelligence => WordPressIntelligenceTests}/IntelligenceTestHelpers.swift (100%) rename Modules/Tests/{WordPressSharedTests/Intelligence => WordPressIntelligenceTests}/IntelligenceUtilitiesTests.swift (99%) diff --git a/Modules/Package.swift b/Modules/Package.swift index 05537222fc35..227794c6ea38 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -20,6 +20,7 @@ let package = Package( .library(name: "WordPressFlux", targets: ["WordPressFlux"]), .library(name: "WordPressShared", targets: ["WordPressShared"]), .library(name: "WordPressUI", targets: ["WordPressUI"]), + .library(name: "WordPressIntelligence", targets: ["WordPressIntelligence"]), .library(name: "WordPressReader", targets: ["WordPressReader"]), .library(name: "WordPressCore", targets: ["WordPressCore"]), .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), @@ -163,6 +164,10 @@ let package = Package( // This package should never have dependencies – it exists to expose protocols implemented in WordPressCore // to UI code, because `wordpress-rs` doesn't work nicely with previews. ]), + .target(name: "WordPressIntelligence", dependencies: [ + "WordPressShared", + .product(name: "SwiftSoup", package: "SwiftSoup"), + ]), .target(name: "WordPressLegacy", dependencies: ["DesignSystem", "WordPressShared"]), .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target( @@ -251,6 +256,7 @@ let package = Package( .testTarget(name: "WordPressSharedObjCTests", dependencies: [.target(name: "WordPressShared"), .target(name: "WordPressTesting")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget(name: "WordPressUIUnitTests", dependencies: [.target(name: "WordPressUI")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget(name: "WordPressCoreTests", dependencies: [.target(name: "WordPressCore")]), + .testTarget(name: "WordPressIntelligenceTests", dependencies: [.target(name: "WordPressIntelligence")]) ] ) diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift similarity index 52% rename from Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift rename to Modules/Sources/WordPressIntelligence/IntelligenceService.swift index 0cfdc01ffae9..1b3a552ca23f 100644 --- a/Modules/Sources/WordPressShared/Intelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift @@ -71,82 +71,4 @@ public actor IntelligenceService { let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio return String((extract ?? post).prefix(Int(postSizeLimit))) } - - // MARK: - Shared Parameters - - /// 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: - 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") - } - } - - 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" - } - } - } - - /// Target length for generated text. - public enum WritingLength: Int, CaseIterable, Sendable { - 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 { - switch self { - case .short: "short" - case .medium: "medium" - case .long: "long" - } - } - - public var promptModifier: String { - "\(wordRange) words" - } - - private var wordRange: String { - switch self { - case .short: "20-40" - case .medium: "50-70" - case .long: "120-180" - } - } - } } diff --git a/Modules/Sources/WordPressShared/Intelligence/IntelligenceUtilities.swift b/Modules/Sources/WordPressIntelligence/IntelligenceUtilities.swift similarity index 100% rename from Modules/Sources/WordPressShared/Intelligence/IntelligenceUtilities.swift rename to Modules/Sources/WordPressIntelligence/IntelligenceUtilities.swift diff --git a/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift b/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift new file mode 100644 index 000000000000..b9db14d8af91 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/Parameters/ContentLength.swift @@ -0,0 +1,40 @@ +import Foundation +import WordPressShared + +/// Target length for generated text. +public enum ContentLength: Int, CaseIterable, Sendable { + case short + case medium + case long + + public var displayName: String { + switch self { + case .short: + AppLocalizedString("generation.length.short", value: "Short", comment: "Generated content length (needs to be short)") + case .medium: + AppLocalizedString("generation.length.medium", value: "Medium", comment: "Generated content length (needs to be short)") + case .long: + AppLocalizedString("generation.length.long", value: "Long", comment: "Generated content length (needs to be short)") + } + } + + public var trackingName: String { + switch self { + case .short: "short" + case .medium: "medium" + case .long: "long" + } + } + + public var promptModifier: String { + "\(wordRange) words" + } + + private var wordRange: String { + switch self { + case .short: "20-40" + case .medium: "50-70" + case .long: "120-180" + } + } +} diff --git a/Modules/Sources/WordPressIntelligence/Parameters/WritingStyle.swift b/Modules/Sources/WordPressIntelligence/Parameters/WritingStyle.swift new file mode 100644 index 000000000000..0d39098730fe --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/Parameters/WritingStyle.swift @@ -0,0 +1,40 @@ +import Foundation +import WordPressShared + +/// Writing style for generated text. +public enum WritingStyle: String, CaseIterable, Sendable { + case engaging + case conversational + case witty + case formal + case professional + + public var displayName: String { + switch self { + case .engaging: + AppLocalizedString("generation.style.engaging", value: "Engaging", comment: "AI generation style") + case .conversational: + AppLocalizedString("generation.style.conversational", value: "Conversational", comment: "AI generation style") + case .witty: + AppLocalizedString("generation.style.witty", value: "Witty", comment: "AI generation style") + case .formal: + AppLocalizedString("generation.style.formal", value: "Formal", comment: "AI generation style") + case .professional: + AppLocalizedString("generation.style.professional", value: "Professional", comment: "AI generation style") + } + } + + var promptModifier: String { + "\(rawValue) (\(promptModifierDetails))" + } + + var promptModifierDetails: String { + switch self { + case .engaging: "engaging and compelling tone" + case .witty: "witty, creative, entertaining" + case .conversational: "friendly and conversational tone" + case .formal: "formal and academic tone" + case .professional: "professional and polished tone" + } + } +} diff --git a/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift new file mode 100644 index 000000000000..1f11bbb59f45 --- /dev/null +++ b/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift @@ -0,0 +1,100 @@ +import Foundation +import FoundationModels + + /// Excerpt generation for WordPress posts. + /// + /// Generates multiple excerpt variations for blog posts with customizable + /// length and writing style. Supports session-based usage (for UI with continuity) + /// and one-shot generation (for tests and background tasks). + @available(iOS 26, *) +public struct ExcerptGeneration { + public let length: ContentLength + public let 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 + } + + // MARK: - Instance Methods + + /// Creates a language model session configured for excerpt generation. + public func makeSession() -> LanguageModelSession { + LanguageModelSession( + model: .init(guardrails: .permissiveContentTransformations), + instructions: Self.instructions + ) + } + + /// Creates a prompt for this excerpt configuration. + public func makePrompt(content: String) -> String { + """ + Generate three different excerpts for the given post and parameters + + GENERATED_CONTENT_LENGTH: \(length.promptModifier) + + GENERATION_STYLE: \(style.promptModifier) + + POST_CONTENT: ''' + \(content) + """ + } + + /// Generates excerpts with this configuration. + public func generate(content: String) async throws -> [String] { + let extractedContent = IntelligenceService.extractRelevantText(from: content) + let session = makeSession() + + let response = try await session.respond( + to: makePrompt(content: extractedContent), + generating: Result.self, + options: options + ) + + return response.content.excerpts + } + + // MARK: - Building Blocks (for UI with session continuity) + + /// Instructions for the language model session. + public static var instructions: String { + """ + Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. + + Generate excerpts in the same language as the POST_CONTENT. + + **Paramters** + - POST_CONTENT: contents of the post (HTML or plain text) + - GENERATED_CONTENT_LENGTH: the length of the generated content + - GENERATION_STYLE: the writing style to follow + + **Requirements** + - Each excerpt must follow the provided GENERATED_CONTENT_LENGTH and use GENERATION_STYLE + + **Excerpt best practices** + - Follow the best practices for post excerpts esteblished in the WordPress ecosystem + - Include the post's main value proposition + - Use active voice (avoid "is", "are", "was", "were" when possible) + - End with implicit promise of more information + - Do not use ellipsis (...) at the end + - Focus on value, not summary + - Include strategic keywords naturally + - Write independently from the introduction – excerpt shouldn't just duplicate your opening paragraph. While your introduction eases readers into the topic, your excerpt needs to work as standalone copy that makes sense out of context—whether it appears in search results, social media cards, or email newsletters. + """ + } + + /// Prompt for generating additional excerpt options. + public static var loadMorePrompt: String { + "Generate additional three options" + } + + // MARK: - Result Type + + @Generable + public struct Result { + @Guide(description: "Three different excerpt variations") + public var excerpts: [String] + } +} diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift b/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift similarity index 100% rename from Modules/Sources/WordPressShared/Intelligence/UseCases/PostSummary.swift rename to Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift b/Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummary.swift similarity index 100% rename from Modules/Sources/WordPressShared/Intelligence/UseCases/SupportTicketSummary.swift rename to Modules/Sources/WordPressIntelligence/UseCases/SupportTicketSummary.swift diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift b/Modules/Sources/WordPressIntelligence/UseCases/TagSuggestion.swift similarity index 99% rename from Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift rename to Modules/Sources/WordPressIntelligence/UseCases/TagSuggestion.swift index 40ccd7bbb8cf..5aedeb890c07 100644 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/TagSuggestion.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/TagSuggestion.swift @@ -1,5 +1,6 @@ import Foundation import FoundationModels +import WordPressShared @available(iOS 26, *) extension IntelligenceService { diff --git a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift deleted file mode 100644 index 14161b228130..000000000000 --- a/Modules/Sources/WordPressShared/Intelligence/UseCases/ExcerptGeneration.swift +++ /dev/null @@ -1,110 +0,0 @@ -import Foundation -import FoundationModels - -extension IntelligenceService { - /// Excerpt generation for WordPress posts. - /// - /// Generates multiple excerpt variations for blog posts with customizable - /// length and writing style. Supports session-based usage (for UI with continuity) - /// and one-shot generation (for tests and background tasks). - @available(iOS 26, *) - public struct ExcerptGeneration { - public let length: Length - public let style: Style - public var options: GenerationOptions - - public init(length: Length, style: Style, options: GenerationOptions = GenerationOptions(temperature: 0.7)) { - self.length = length - self.style = style - self.options = options - } - - public typealias Style = IntelligenceService.WritingStyle - public typealias Length = IntelligenceService.WritingLength - - // MARK: - Instance Methods - - /// Creates a language model session configured for excerpt generation. - public func makeSession() -> LanguageModelSession { - LanguageModelSession( - model: .init(guardrails: .permissiveContentTransformations), - instructions: Self.instructions - ) - } - - /// Creates a prompt for this excerpt configuration. - public func makePrompt(content: String) -> String { - Self.makePrompt(content: content, length: length, style: style) - } - - /// Generates excerpts with this configuration. - public func generate(content: String) async throws -> [String] { - let extractedContent = IntelligenceService.extractRelevantText(from: content) - let session = makeSession() - - let response = try await session.respond( - to: makePrompt(content: extractedContent), - generating: Result.self, - options: options - ) - - return response.content.excerpts - } - - // MARK: - Building Blocks (for UI with session continuity) - - /// Instructions for the language model session. - public static var instructions: String { - """ - Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. - - Generate excerpts in the same language as the POST_CONTENT. - - **Paramters** - - POST_CONTENT: contents of the post (HTML or plain text) - - GENERATED_CONTENT_LENGTH: the length of the generated content - - GENERATION_STYLE: the writing style to follow - - **Requirements** - - Each excerpt must follow the provided GENERATED_CONTENT_LENGTH and use GENERATION_STYLE - - **Excerpt best practices** - - Follow the best practices for post excerpts esteblished in the WordPress ecosystem - - Include the post's main value proposition - - Use active voice (avoid "is", "are", "was", "were" when possible) - - End with implicit promise of more information - - Do not use ellipsis (...) at the end - - Focus on value, not summary - - Include strategic keywords naturally - - Write independently from the introduction – excerpt shouldn't just duplicate your opening paragraph. While your introduction eases readers into the topic, your excerpt needs to work as standalone copy that makes sense out of context—whether it appears in search results, social media cards, or email newsletters. - """ - } - - /// Creates a prompt for generating excerpts. - public static func makePrompt(content: String, length: Length, style: Style) -> String { - """ - Generate three different excerpts for the given post and parameters - - GENERATED_CONTENT_LENGTH: \(length.promptModifier) - - GENERATION_STYLE: \(style.promptModifier) - - POST_CONTENT: ''' - \(content) - """ - } - - /// Prompt for generating additional excerpt options. - public static var loadMorePrompt: String { - "Generate additional three options" - } - - // MARK: - Result Type - - @Generable - public struct Result { - @Guide(description: "Three different excerpt variations") - public var excerpts: [String] - } - } -} diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift b/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift similarity index 98% rename from Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift rename to Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift index 72d6aed18ce6..34f79a81920b 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift @@ -1,6 +1,6 @@ import Testing import FoundationModels -@testable import WordPressShared +@testable import WordPressIntelligence /// Tests for excerpt generation with different locales and languages. /// @@ -41,7 +41,6 @@ import FoundationModels /// - No ellipsis (...) at the end /// - Focus on value, not just summary /// - Work as standalone content (independent from introduction) -@Suite//(.disabled("Manual tests - Run individually to test excerpt generation")) struct IntelligenceExcerptGenerationTests { // MARK: - Parameterized Language & Style Tests diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift similarity index 99% rename from Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift rename to Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift index f703c8519e19..2ac7f90a2d70 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligencePostSummaryTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import WordPressShared +@testable import WordPressIntelligence /// Tests for post summarization with different locales and languages. /// diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceServiceTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift similarity index 90% rename from Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceServiceTests.swift rename to Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift index b9f4a8adccf6..ca506e7f815e 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceServiceTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import WordPressShared +@testable import WordPressIntelligence struct IntelligenceServiceTests { @available(iOS 26, *) diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift similarity index 99% rename from Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift rename to Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift index b03082cfe1fb..c3533202017c 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceSuggestedTagsTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import WordPressShared +@testable import WordPressIntelligence /// Tests for tag suggestion with different locales and languages. /// diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceTestData.swift similarity index 100% rename from Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestData.swift rename to Modules/Tests/WordPressIntelligenceTests/IntelligenceTestData.swift diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceTestHelpers.swift similarity index 100% rename from Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceTestHelpers.swift rename to Modules/Tests/WordPressIntelligenceTests/IntelligenceTestHelpers.swift diff --git a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceUtilitiesTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceUtilitiesTests.swift similarity index 99% rename from Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceUtilitiesTests.swift rename to Modules/Tests/WordPressIntelligenceTests/IntelligenceUtilitiesTests.swift index 657644c0e0fd..0df8967a564d 100644 --- a/Modules/Tests/WordPressSharedTests/Intelligence/IntelligenceUtilitiesTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligenceUtilitiesTests.swift @@ -1,5 +1,5 @@ import Testing -@testable import WordPressShared +@testable import WordPressIntelligence struct IntelligenceUtilitiesTests { @Test func extractRelevantText() throws { From 42b0f775cd60a53bde2ebec81125d50e38cf35db Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 5 Dec 2025 12:06:03 -0500 Subject: [PATCH 13/13] Update the tests --- .../IntelligenceService.swift | 6 +- .../UseCases/ExcerptGeneration.swift | 110 +++++----- .../UseCases/PostSummary.swift | 4 +- .../ContentExtractor.swift} | 2 +- ...ests.swift => ContentExtractorTests.swift} | 10 +- .../ExcerptGenerationTests.swift | 204 ++++-------------- .../IntelligencePostSummaryTests.swift | 21 +- .../IntelligenceServiceTests.swift | 2 +- .../IntelligenceSuggestedTagsTests.swift | 14 +- ...elligenceTestData.swift => TestData.swift} | 2 +- ...nceTestHelpers.swift => TestHelpers.swift} | 2 +- .../Views/ReaderSummarizePostView.swift | 11 +- 12 files changed, 125 insertions(+), 263 deletions(-) rename Modules/Sources/WordPressIntelligence/{IntelligenceUtilities.swift => Utilities/ContentExtractor.swift} (98%) rename Modules/Tests/WordPressIntelligenceTests/{IntelligenceUtilitiesTests.swift => ContentExtractorTests.swift} (95%) rename Modules/Tests/WordPressIntelligenceTests/{IntelligenceTestData.swift => TestData.swift} (99%) rename Modules/Tests/WordPressIntelligenceTests/{IntelligenceTestHelpers.swift => TestHelpers.swift} (99%) diff --git a/Modules/Sources/WordPressIntelligence/IntelligenceService.swift b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift index 1b3a552ca23f..289b59474570 100644 --- a/Modules/Sources/WordPressIntelligence/IntelligenceService.swift +++ b/Modules/Sources/WordPressIntelligence/IntelligenceService.swift @@ -48,8 +48,8 @@ public actor IntelligenceService { /// Summarizes a WordPress post. @available(iOS 26, *) - public func summarizePost(content: String) -> LanguageModelSession.ResponseStream { - PostSummary.execute(content: content) + public func summarizePost(content: String) async throws -> String { + try await PostSummary.execute(content: content) } /// Summarizes a support ticket to a short title. @@ -67,7 +67,7 @@ public actor IntelligenceService { /// Extracts relevant text from post content, removing HTML and limiting size. public nonisolated static func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String { - let extract = try? IntelligenceUtilities.extractRelevantText(from: post) + let extract = try? ContentExtractor.extractRelevantText(from: post) let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio return String((extract ?? post).prefix(Int(postSizeLimit))) } diff --git a/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift b/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift index 1f11bbb59f45..371eea02a48a 100644 --- a/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/ExcerptGeneration.swift @@ -1,24 +1,37 @@ 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, *) +/// Excerpt generation for WordPress posts. +/// +/// Generates multiple excerpt variations for blog posts with customizable +/// length and writing style. Supports session-based usage (for UI with continuity) +/// and one-shot generation (for tests and background tasks). +@available(iOS 26, *) public struct ExcerptGeneration { - public let length: ContentLength - public let style: WritingStyle + public var length: ContentLength + public var style: WritingStyle public var options: GenerationOptions - public init(length: ContentLength, style: WritingStyle, options: GenerationOptions = GenerationOptions(temperature: 0.7)) { + public init( + length: ContentLength, + style: WritingStyle, + options: GenerationOptions = GenerationOptions(temperature: 0.7) + ) { self.length = length self.style = style self.options = options } - // MARK: - Instance Methods + /// Generates excerpts with this configuration. + public func generate(for content: String) async throws -> [String] { + let content = IntelligenceService.extractRelevantText(from: content) + let response = try await makeSession().respond( + to: makePrompt(content: content), + generating: Result.self, + options: options + ) + return response.content.excerpts + } /// Creates a language model session configured for excerpt generation. public func makeSession() -> LanguageModelSession { @@ -28,63 +41,48 @@ public struct ExcerptGeneration { ) } + /// Instructions for the language model session. + public static var instructions: String { + """ + Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. + + Generate excerpts in the same language as the POST_CONTENT. + + **Paramters** + - POST_CONTENT: contents of the post (HTML or plain text) + - GENERATED_CONTENT_LENGTH: the length of the generated content + - GENERATION_STYLE: the writing style to follow + + **Requirements** + - Each excerpt must follow the provided GENERATED_CONTENT_LENGTH and use GENERATION_STYLE + + **Excerpt best practices** + - Follow the best practices for post excerpts esteblished in the WordPress ecosystem + - Include the post's main value proposition + - Use active voice (avoid "is", "are", "was", "were" when possible) + - End with implicit promise of more information + - Do not use ellipsis (...) at the end + - Focus on value, not summary + - Include strategic keywords naturally + - Write independently from the introduction – excerpt shouldn't just duplicate your opening paragraph. While your introduction eases readers into the topic, your excerpt needs to work as standalone copy that makes sense out of context—whether it appears in search results, social media cards, or email newsletters. + """ + } + + /// Creates a prompt for this excerpt configuration. public func makePrompt(content: String) -> String { """ Generate three different excerpts for the given post and parameters - + GENERATED_CONTENT_LENGTH: \(length.promptModifier) - + GENERATION_STYLE: \(style.promptModifier) - + POST_CONTENT: ''' \(content) """ } - /// Generates excerpts with this configuration. - public func generate(content: String) async throws -> [String] { - let extractedContent = IntelligenceService.extractRelevantText(from: content) - let session = makeSession() - - let response = try await session.respond( - to: makePrompt(content: extractedContent), - generating: Result.self, - options: options - ) - - return response.content.excerpts - } - - // MARK: - Building Blocks (for UI with session continuity) - - /// Instructions for the language model session. - public static var instructions: String { - """ - Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style. - - Generate excerpts in the same language as the POST_CONTENT. - - **Paramters** - - POST_CONTENT: contents of the post (HTML or plain text) - - GENERATED_CONTENT_LENGTH: the length of the generated content - - GENERATION_STYLE: the writing style to follow - - **Requirements** - - Each excerpt must follow the provided GENERATED_CONTENT_LENGTH and use GENERATION_STYLE - - **Excerpt best practices** - - Follow the best practices for post excerpts esteblished in the WordPress ecosystem - - Include the post's main value proposition - - Use active voice (avoid "is", "are", "was", "were" when possible) - - End with implicit promise of more information - - Do not use ellipsis (...) at the end - - Focus on value, not summary - - Include strategic keywords naturally - - Write independently from the introduction – excerpt shouldn't just duplicate your opening paragraph. While your introduction eases readers into the topic, your excerpt needs to work as standalone copy that makes sense out of context—whether it appears in search results, social media cards, or email newsletters. - """ - } - /// Prompt for generating additional excerpt options. public static var loadMorePrompt: String { "Generate additional three options" diff --git a/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift b/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift index 1f980e2e5e39..5f82bed379fc 100644 --- a/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift +++ b/Modules/Sources/WordPressIntelligence/UseCases/PostSummary.swift @@ -8,7 +8,7 @@ extension IntelligenceService { /// Generates concise summaries that capture the main points and key information /// from WordPress post content in the same language as the source. public enum PostSummary { - static func execute(content: String) -> LanguageModelSession.ResponseStream { + static func execute(content: String) async throws -> String { let content = IntelligenceService.extractRelevantText(from: content, ratio: 0.8) let instructions = """ @@ -31,7 +31,7 @@ extension IntelligenceService { \(content) """ - return session.streamResponse(to: prompt) + return try await session.respond(to: prompt).content } } } diff --git a/Modules/Sources/WordPressIntelligence/IntelligenceUtilities.swift b/Modules/Sources/WordPressIntelligence/Utilities/ContentExtractor.swift similarity index 98% rename from Modules/Sources/WordPressIntelligence/IntelligenceUtilities.swift rename to Modules/Sources/WordPressIntelligence/Utilities/ContentExtractor.swift index 47406b0ed1e9..e6c0c1bf522b 100644 --- a/Modules/Sources/WordPressIntelligence/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/Tests/WordPressIntelligenceTests/IntelligenceUtilitiesTests.swift b/Modules/Tests/WordPressIntelligenceTests/ContentExtractorTests.swift similarity index 95% rename from Modules/Tests/WordPressIntelligenceTests/IntelligenceUtilitiesTests.swift rename to Modules/Tests/WordPressIntelligenceTests/ContentExtractorTests.swift index 0df8967a564d..ccec0ddb38db 100644 --- a/Modules/Tests/WordPressIntelligenceTests/IntelligenceUtilitiesTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/ContentExtractorTests.swift @@ -1,9 +1,9 @@ import Testing @testable import WordPressIntelligence -struct IntelligenceUtilitiesTests { +struct ContentExtractorTests { @Test func extractRelevantText() throws { - let text = try IntelligenceUtilities.extractRelevantText(from: IntelligenceUtilities.post) + let text = try ContentExtractor.extractRelevantText(from: ContentExtractor.post) #expect(text == """

The Art of Making Perfect Sourdough Bread at Home

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

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

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

The Art of Making Perfect Sourdough Bread at Home

diff --git a/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift b/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift index 34f79a81920b..b39f98be0491 100644 --- a/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/ExcerptGenerationTests.swift @@ -43,201 +43,73 @@ import FoundationModels /// - Work as standalone content (independent from introduction) struct IntelligenceExcerptGenerationTests { - // MARK: - Parameterized Language & Style Tests - @available(iOS 26, *) - @Test(arguments: ExcerptTestCase.allCases) - func excerptGeneration(testCase: ExcerptTestCase) async throws { - let excerpts = try await generateExcerpts( - content: testCase.testData.content, - length: testCase.length, - style: testCase.style - ) + @Test(.serialized, arguments: ExcerptTestCaseParameters.allCases) + func excerptGeneration(parameters: ExcerptTestCaseParameters) async throws { + let generator = ExcerptGeneration(length: parameters.length, style: parameters.style) + let excerpts = try await generator.generate(for: parameters.data.content) - IntelligenceTestHelpers.printExcerptResults( - testCase.testDescription, + TestHelpers.printExcerptResults( + parameters.testDescription, excerpts: excerpts, - targetLength: testCase.length.promptModifier, - style: testCase.style.displayName + targetLength: parameters.length.promptModifier, + style: parameters.style.displayName ) } - // MARK: - Comprehensive Tests - - typealias Data = IntelligenceTestData - - @available(iOS 26, *) - @Test("All styles for Spanish content") - func spanishAllStyles() async throws { - let testData = Data.spanishPost - var rows: [[String]] = [] - - for style in IntelligenceService.ExcerptGeneration.Style.allCases { - let excerpts = try await generateExcerpts( - content: testData.content, - length: .medium, - style: style - ) - - // Use first excerpt for comparison - let excerpt = excerpts.first ?? "" - let wordCount = excerpt.split(separator: " ").count - let preview = String(excerpt.prefix(60)) - - rows.append([ - style.displayName, - "\(wordCount) words", - preview - ]) - } - - IntelligenceTestHelpers.printComparisonTable( - "Spanish Excerpts - All Styles Comparison (Medium Length)", - headers: ["Style", "Length", "Preview"], - rows: rows - ) - } - - @available(iOS 26, *) - @Test("All lengths for English content") - func englishAllLengths() async throws { - let testData = Data.englishTechPost - var rows: [[String]] = [] - - for length in IntelligenceService.ExcerptGeneration.Length.allCases { - let excerpts = try await generateExcerpts( - content: testData.content, - length: length, - style: .engaging - ) - - // Use first excerpt for comparison - let excerpt = excerpts.first ?? "" - let wordCount = excerpt.split(separator: " ").count - let preview = String(excerpt.prefix(60)) - - let expectedRange: String - switch length { - case .short: expectedRange = "20-40" - case .medium: expectedRange = "50-70" - case .long: expectedRange = "120-180" - } - - rows.append([ - length.displayName, - expectedRange, - "\(wordCount)", - preview - ]) - } - - IntelligenceTestHelpers.printComparisonTable( - "English Excerpts - All Lengths Comparison (Engaging Style)", - headers: ["Length", "Expected", "Actual", "Preview"], - rows: rows - ) - } - - // MARK: - HTML Content Tests - @available(iOS 26, *) @Test("Spanish HTML content") func spanishHTMLContent() async throws { - let testData = Data.spanishPostWithHTML - let excerpts = try await generateExcerpts( - content: testData.content, - length: .medium, - style: .engaging - ) - - print("Excerpts from Spanish HTML content:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - } + let testData = TestData.spanishPostWithHTML + let generator = ExcerptGeneration(length: .medium, style: .engaging) + let excerpts = try await generator.generate(for: testData.content) - print("\nExpected: Clean excerpts without HTML tags, in Spanish") + TestHelpers.printExcerptResults( + testData.title, + excerpts: excerpts, + targetLength: generator.length.promptModifier, + style: generator.style.displayName + ) } - // MARK: - Edge Cases - @available(iOS 26, *) @Test("Very short content") func veryShortContent() async throws { let shortContent = "La inteligencia artificial está transformando nuestro mundo." - let excerpts = try await generateExcerpts( - content: shortContent, - length: .short, - style: .engaging - ) - - print("Excerpts from very short content:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - } - - print("\nExpected: Reasonable excerpts even with limited source content") - } - - @available(iOS 26, *) - @Test("Content with special characters") - func specialCharacters() async throws { - let content = """ - ¿Sabías que el símbolo @ tiene más de 500 años? Este carácter, conocido como "arroba" - en español, se utilizaba originalmente en documentos comerciales para indicar "al precio de". - Hoy en día, es imposible imaginar el correo electrónico o las redes sociales sin él. - """ + let generator = ExcerptGeneration(length: .short, style: .engaging) + let excerpts = try await generator.generate(for: shortContent) - let excerpts = try await generateExcerpts( - content: content, - length: .short, - style: .witty + TestHelpers.printExcerptResults( + "Very short content", + excerpts: excerpts, + targetLength: generator.length.promptModifier, + style: generator.style.displayName ) - - print("Excerpts with special characters:") - excerpts.enumerated().forEach { index, excerpt in - print("\n[\(index + 1)] \(excerpt)") - } - - print("\nExpected: Proper handling of Spanish special characters") - } - - // MARK: - Helper Methods - - @available(iOS 26, *) - private func generateExcerpts( - content: String, - length: IntelligenceService.ExcerptGeneration.Length, - style: IntelligenceService.ExcerptGeneration.Style - ) async throws -> [String] { - let generator = IntelligenceService.ExcerptGeneration(length: length, style: style) - return try await generator.generate(content: content) } } -// MARK: - Test Cases - -struct ExcerptTestCase: CustomTestStringConvertible { - let testData: TestContent - let length: IntelligenceService.ExcerptGeneration.Length - let style: IntelligenceService.ExcerptGeneration.Style +struct ExcerptTestCaseParameters: CustomTestStringConvertible { + let data: TestContent + let length: ContentLength + let style: WritingStyle var testDescription: String { - "\(testData.title) - \(length.displayName) \(style.displayName)" + "\(data.title) - \(length.displayName) \(style.displayName)" } - typealias Data = IntelligenceTestData + typealias Data = TestData - static let allCases: [ExcerptTestCase] = [ + static let allCases: [ExcerptTestCaseParameters] = [ // English - ExcerptTestCase(testData: Data.englishTechPost, length: .short, style: .witty), - ExcerptTestCase(testData: Data.englishAcademicPost, length: .medium, style: .formal), - ExcerptTestCase(testData: Data.englishStoryPost, length: .long, style: .engaging), + ExcerptTestCaseParameters(data: Data.englishTechPost, length: .short, style: .witty), + ExcerptTestCaseParameters(data: Data.englishAcademicPost, length: .medium, style: .formal), + ExcerptTestCaseParameters(data: Data.englishStoryPost, length: .long, style: .engaging), // Other - ExcerptTestCase(testData: Data.spanishPost, length: .medium, style: .professional), - ExcerptTestCase(testData: Data.frenchPost, length: .short, style: .engaging), - ExcerptTestCase(testData: Data.japanesePost, length: .medium, style: .conversational), - ExcerptTestCase(testData: Data.germanTechPost, length: .short, style: .professional), + ExcerptTestCaseParameters(data: Data.spanishPost, length: .medium, style: .professional), + ExcerptTestCaseParameters(data: Data.frenchPost, length: .short, style: .engaging), + ExcerptTestCaseParameters(data: Data.japanesePost, length: .medium, style: .conversational), + ExcerptTestCaseParameters(data: Data.germanTechPost, length: .short, style: .professional), ] } diff --git a/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift index 2ac7f90a2d70..2895f5208f45 100644 --- a/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligencePostSummaryTests.swift @@ -40,7 +40,7 @@ struct IntelligencePostSummaryTests { func postSummary(testCase: SummaryTestCase) async throws { let summary = try await summarizePost(content: testCase.testData.content) - IntelligenceTestHelpers.printSummaryResults( + TestHelpers.printSummaryResults( testCase.testData.title, summary: summary ) @@ -50,7 +50,7 @@ struct IntelligencePostSummaryTests { // MARK: - Special Content Tests - typealias Data = IntelligenceTestData + typealias Data = TestData @available(iOS 26, *) @Test("Summarize HTML-heavy Spanish post") @@ -58,7 +58,7 @@ struct IntelligencePostSummaryTests { let testData = Data.spanishPostWithHTML let summary = try await summarizePost(content: testData.content) - IntelligenceTestHelpers.printSummaryResults( + TestHelpers.printSummaryResults( testData.title, summary: summary ) @@ -72,7 +72,7 @@ struct IntelligencePostSummaryTests { let testData = Data.spanishReaderArticle let summary = try await summarizePost(content: testData.content) - IntelligenceTestHelpers.printSummaryResults( + TestHelpers.printSummaryResults( testData.title, summary: summary ) @@ -86,7 +86,7 @@ struct IntelligencePostSummaryTests { let testData = Data.englishReaderArticle let summary = try await summarizePost(content: testData.content) - IntelligenceTestHelpers.printSummaryResults( + TestHelpers.printSummaryResults( testData.title, summary: summary ) @@ -99,13 +99,8 @@ struct IntelligencePostSummaryTests { @available(iOS 26, *) private func summarizePost(content: String) async throws -> String { let service = IntelligenceService() - var summary = "" - - for try await chunk in await service.summarizePost(content: content) { - summary = chunk.content - print(chunk.content, terminator: "") - } - + let summary = try await service.summarizePost(content: content) + print(summary) return summary } } @@ -117,7 +112,7 @@ struct SummaryTestCase: CustomTestStringConvertible { var testDescription: String { testData.title } - typealias Data = IntelligenceTestData + typealias Data = TestData static let basicCases: [SummaryTestCase] = [ SummaryTestCase(testData: Data.spanishPost), diff --git a/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift index ca506e7f815e..e606ae23d5c8 100644 --- a/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligenceServiceTests.swift @@ -7,7 +7,7 @@ struct IntelligenceServiceTests { func suggestTags() async throws { let tags = try await IntelligenceService() .suggestTags( - post: IntelligenceUtilities.post, + post: ContentExtractor.post, siteTags: ["cooking", "healthy-foods"] ) print(tags) diff --git a/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift b/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift index c3533202017c..2bffc8eedb92 100644 --- a/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift +++ b/Modules/Tests/WordPressIntelligenceTests/IntelligenceSuggestedTagsTests.swift @@ -51,7 +51,7 @@ struct IntelligenceSuggestedTagsTests { postTags: testCase.postTags ) - IntelligenceTestHelpers.printTagResults( + TestHelpers.printTagResults( testCase.testDescription, tags: tags ) @@ -59,7 +59,7 @@ struct IntelligenceSuggestedTagsTests { // MARK: - Edge Case Tests - typealias Data = IntelligenceTestData + typealias Data = TestData @available(iOS 26, *) @Test("Existing post tags should be excluded") @@ -72,7 +72,7 @@ struct IntelligenceSuggestedTagsTests { postTags: existingTags ) - IntelligenceTestHelpers.printTagResults( + TestHelpers.printTagResults( "\(testData.title) - Exclude Existing Tags: \(existingTags.joined(separator: ", "))", tags: tags ) @@ -90,7 +90,7 @@ struct IntelligenceSuggestedTagsTests { postTags: [] ) - IntelligenceTestHelpers.printTagResults( + TestHelpers.printTagResults( "\(testData.title) - No Site Tags Context", tags: tags ) @@ -105,7 +105,7 @@ struct IntelligenceSuggestedTagsTests { postTags: [] ) - IntelligenceTestHelpers.printTagResults( + TestHelpers.printTagResults( "Spanish - Very Short Content", tags: tags ) @@ -122,7 +122,7 @@ struct IntelligenceSuggestedTagsTests { postTags: [] ) - IntelligenceTestHelpers.printTagResults( + TestHelpers.printTagResults( "\(testData.title) - Very Long Content (Truncated)", tags: tags ) @@ -156,7 +156,7 @@ struct TagTestCase: CustomTestStringConvertible { } } - typealias Data = IntelligenceTestData + typealias Data = TestData static let languageCombinations: [TagTestCase] = [ TagTestCase(testData: Data.spanishPost, siteTags: Data.spanishSiteTags, postTags: [], siteTagsLanguage: "Spanish"), diff --git a/Modules/Tests/WordPressIntelligenceTests/IntelligenceTestData.swift b/Modules/Tests/WordPressIntelligenceTests/TestData.swift similarity index 99% rename from Modules/Tests/WordPressIntelligenceTests/IntelligenceTestData.swift rename to Modules/Tests/WordPressIntelligenceTests/TestData.swift index c4df11472046..f2356d9d9d4f 100644 --- a/Modules/Tests/WordPressIntelligenceTests/IntelligenceTestData.swift +++ b/Modules/Tests/WordPressIntelligenceTests/TestData.swift @@ -10,7 +10,7 @@ struct TestContent { /// /// This enum provides sample content in multiple languages for testing /// excerpt generation, post summarization, and tag suggestion features. -enum IntelligenceTestData { +enum TestData { // MARK: - Spanish Content static let spanishPostWithHTML = TestContent( diff --git a/Modules/Tests/WordPressIntelligenceTests/IntelligenceTestHelpers.swift b/Modules/Tests/WordPressIntelligenceTests/TestHelpers.swift similarity index 99% rename from Modules/Tests/WordPressIntelligenceTests/IntelligenceTestHelpers.swift rename to Modules/Tests/WordPressIntelligenceTests/TestHelpers.swift index 110bf6baee5a..aeaf9ef71468 100644 --- a/Modules/Tests/WordPressIntelligenceTests/IntelligenceTestHelpers.swift +++ b/Modules/Tests/WordPressIntelligenceTests/TestHelpers.swift @@ -1,7 +1,7 @@ import Foundation /// Helper utilities for formatting intelligence test output. -enum IntelligenceTestHelpers { +enum TestHelpers { // MARK: - Tag Suggestions diff --git a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift index 7cf11dcdb604..a99dd54a4530 100644 --- a/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Views/ReaderSummarizePostView.swift @@ -1,7 +1,6 @@ import SwiftUI import WordPressUI import WordPressData -import FoundationModels @available(iOS 26, *) struct ReaderSummarizePostView: View { @@ -72,13 +71,11 @@ struct ReaderSummarizePostView: View { do { let content = post.content ?? "" - let stream = await IntelligenceService().summarizePost(content: content) + let result = try await IntelligenceService().summarizePost(content: content) - for try await result in stream { - guard !Task.isCancelled else { return } - withAnimation(.smooth) { - summary = result.content - } + guard !Task.isCancelled else { return } + withAnimation(.smooth) { + summary = result } } catch { guard !Task.isCancelled else { return }