Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")])
]
)

Expand Down Expand Up @@ -348,6 +354,7 @@ enum XcodeSupport {
"ShareExtensionCore",
"Support",
"WordPressFlux",
"WordPressIntelligence",
"WordPressShared",
"WordPressLegacy",
"WordPressReader",
Expand Down
65 changes: 65 additions & 0 deletions Modules/Sources/WordPressIntelligence/IntelligenceService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import FoundationModels
import NaturalLanguage

public enum IntelligenceService {
/// Maximum context size for language model sessions (in tokens).
///
/// A single token corresponds to three or four characters in languages like
/// English, Spanish, or German, and one token per character in languages like
/// Japanese, Chinese, or Korean. In a single session, the sum of all tokens
/// in the instructions, all prompts, and all outputs count toward the context window size.
///
/// https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models#Consider-context-size-limits-per-session
public static let contextSizeLimit = 4096

/// Checks if intelligence features are supported on the current device.
public nonisolated static var isSupported: Bool {
guard #available(iOS 26, *) else {
return false
}
switch SystemLanguageModel.default.availability {
case .available:
return true
case .unavailable(let reason):
switch reason {
case .appleIntelligenceNotEnabled, .modelNotReady:
return true
case .deviceNotEligible:
return false
@unknown default:
return false
}
}
}

/// Extracts relevant text from post content, removing HTML and limiting size.
public static func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String {
let extract = try? ContentExtractor.extractRelevantText(from: post)
let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio
return String((extract ?? post).prefix(Int(postSizeLimit)))
}

/// - note: As documented in https://developer.apple.com/documentation/foundationmodels/supporting-languages-and-locales-with-foundation-models?changes=_10_5#Use-Instructions-to-set-the-locale-and-language
static func makeLocaleInstructions(for locale: Locale = Locale.current) -> String {
if Locale.Language(identifier: "en_US").isEquivalent(to: locale.language) {
return "" // Skip the locale phrase for U.S. English.
}
return "The person's locale is \(locale.identifier)."
}

/// Detects the dominant language of the given text.
///
/// - Parameter text: The text to analyze
/// - Returns: The detected language code (e.g., "en", "es", "fr", "ja"), or nil if detection fails
public static func detectLanguage(from text: String) -> String? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)

guard let languageCode = recognizer.dominantLanguage else {
return nil
}

return languageCode.rawValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation
import WordPressShared

/// Target length for generated text.
///
/// Ranges are calibrated for English and account for cross-language variance.
/// Sentences are the primary indicator; word counts accommodate language differences.
///
/// - **Short**: 1-2 sentences (15-35 words) - Social media, search snippets
/// - **Medium**: 2-4 sentences (30-90 words) - RSS feeds, blog listings
/// - **Long**: 5-7 sentences (90-130 words) - Detailed previews, newsletters
///
/// Word ranges are intentionally wide (2-2.3x) to handle differences in language
/// structure (German compounds, Romance wordiness, CJK tokenization).
public enum ContentLength: Int, CaseIterable, Sendable {
case short
case medium
case long

public var displayName: String {
switch self {
case .short:
AppLocalizedString("generation.length.short", value: "Short", comment: "Generated content length (needs to be short)")
case .medium:
AppLocalizedString("generation.length.medium", value: "Medium", comment: "Generated content length (needs to be short)")
case .long:
AppLocalizedString("generation.length.long", value: "Long", comment: "Generated content length (needs to be short)")
}
}

public var trackingName: String {
switch self {
case .short: "short"
case .medium: "medium"
case .long: "long"
}
}

public var promptModifier: String {
"\(sentenceRange.lowerBound)-\(sentenceRange.upperBound) sentences (\(wordRange.lowerBound)-\(wordRange.upperBound) words)"
}

public var sentenceRange: ClosedRange<Int> {
switch self {
case .short: 1...2
case .medium: 2...4
case .long: 5...7
}
}

public var wordRange: ClosedRange<Int> {
switch self {
case .short: 15...35
case .medium: 40...80
case .long: 90...130
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import Foundation
import FoundationModels

/// Excerpt generation for WordPress posts.
///
/// Generates multiple excerpt variations for blog posts with customizable
/// length and writing style. Supports session-based usage (for UI with continuity)
/// and one-shot generation (for tests and background tasks).
@available(iOS 26, *)
public struct PostExcerptGenerator {
public var length: ContentLength
public var style: WritingStyle
public var options: GenerationOptions

public init(
length: ContentLength,
style: WritingStyle,
options: GenerationOptions = GenerationOptions(temperature: 0.7)
) {
self.length = length
self.style = style
self.options = options
}

/// Generates excerpts with this configuration.
public func generate(for content: String) async throws -> [String] {
let prompt = await makePrompt(content: content)
let response = try await makeSession().respond(
to: prompt,
generating: Result.self,
options: options
)
return response.content.excerpts
}

/// Creates a language model session configured for excerpt generation.
public func makeSession() -> LanguageModelSession {
LanguageModelSession(
model: .init(guardrails: .permissiveContentTransformations),
instructions: Self.instructions
)
}

/// Instructions for the language model session.
public static var instructions: String {
"""
You are helping a WordPress user generate an excerpt for their post or page.

**Parameters**
- POST_CONTENT: post contents (HTML or plain text)
- TARGET_LANGUAGE: detected language code (e.g., "en", "es", "fr", "ja")
- TARGET_LENGTH: sentence count (primary) and word range (secondary)
- GENERATION_STYLE: writing style to apply

\(IntelligenceService.makeLocaleInstructions())

**Requirements**
1. ⚠️ LANGUAGE: Match TARGET_LANGUAGE code if provided, otherwise match POST_CONTENT language. Never translate or default to English.

2. ⚠️ LENGTH: Match TARGET_LENGTH sentence count, stay within word range. Write complete sentences only.

3. ⚠️ STYLE: Follow GENERATION_STYLE exactly.

**Best Practices**
- Capture the post's main value proposition
- Use active voice and strategic keywords naturally
- Don't duplicate the opening paragraph
- Work as standalone copy for search results, social media, and email
"""
}

/// Creates a prompt for this excerpt configuration.
///
/// This method handles content extraction (removing HTML, limiting size) and language detection
/// automatically before creating the prompt.
///
/// - Parameter content: The raw post content (may include HTML)
/// - Returns: The formatted prompt ready for the language model
public func makePrompt(content: String) async -> String {
let extractedContent = IntelligenceService.extractRelevantText(from: content)
let language = IntelligenceService.detectLanguage(from: extractedContent)
let languageInstruction = language.map { "TARGET_LANGUAGE: \($0)\n" } ?? ""

return """
Generate EXACTLY 3 different excerpts for the given post.

\(languageInstruction)TARGET_LENGTH: \(length.promptModifier)
CRITICAL: Write \(length.sentenceRange.lowerBound)-\(length.sentenceRange.upperBound) complete sentences. Stay within \(length.wordRange.lowerBound)-\(length.wordRange.upperBound) words.

GENERATION_STYLE: \(style.promptModifier)

POST_CONTENT:
\(extractedContent)
"""
}

/// Prompt for generating additional excerpt options.
public static var loadMorePrompt: String {
"Generate 3 additional excerpts following the same TARGET_LENGTH and GENERATION_STYLE requirements"
}

// MARK: - Result Type

@Generable
public struct Result {
@Guide(description: "Suggested post excerpts", .count(3))
public var excerpts: [String]
}
}
Loading