Skip to content
Open
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
68 changes: 68 additions & 0 deletions Modules/Sources/WordPressIntelligence/IntelligenceService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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)))
}

// 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) {
// Skip the locale phrase for U.S. English.
return ""
} else {
// Specify the person's locale with the exact phrase format.
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,115 @@
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 response = try await makeSession().respond(
to: makePrompt(content: content),
generating: Result.self,
options: options
)
return response.content.excerpts
}

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

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

**Prompt Parameters**
- POST_CONTENT: contents of the post (HTML or plain text)
- TARGET_LANGUAGE: the detected language code of POST_CONTENT (e.g., "en", "es", "fr", "ja") when available
- TARGET_LENGTH: MANDATORY sentence count (primary) and word count (secondary) for each excerpt
- GENERATION_STYLE: the writing style to follow

\(IntelligenceService.makeLocaleInstructions())

**CRITICAL Requirements (MUST be followed exactly)**
1. ⚠️ LANGUAGE: Generate excerpts in the language specified by TARGET_LANGUAGE code if provided, otherwise match POST_CONTENT language exactly. NO translation. NO defaulting to English. Match input language EXACTLY.

2. ⚠️ LENGTH: Each excerpt MUST match the TARGET_LENGTH specification.
- PRIMARY: Match the sentence count (e.g., "1-2 sentences" means write 1 or 2 complete sentences)
- SECONDARY: Stay within the word count range (accommodates language differences)
- Write complete sentences only. Count sentences after writing.
- VERIFY both sentence and word counts before responding.

3. ⚠️ STYLE: Follow the GENERATION_STYLE exactly (witty, professional, engaging, etc.)

**Excerpt best practices**
- Follow WordPress ecosystem best practices for post excerpts
- 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)
- Include strategic keywords naturally
- Write independently from the introduction – don't duplicate the opening paragraph
- Make excerpts 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) -> 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