Skip to content

Commit ceec1fe

Browse files
committed
Create WordPressIntelligence module and enable for other locales
1 parent ee6e037 commit ceec1fe

29 files changed

+2838
-352
lines changed

Modules/Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let package = Package(
2020
.library(name: "WordPressFlux", targets: ["WordPressFlux"]),
2121
.library(name: "WordPressShared", targets: ["WordPressShared"]),
2222
.library(name: "WordPressUI", targets: ["WordPressUI"]),
23+
.library(name: "WordPressIntelligence", targets: ["WordPressIntelligence"]),
2324
.library(name: "WordPressReader", targets: ["WordPressReader"]),
2425
.library(name: "WordPressCore", targets: ["WordPressCore"]),
2526
.library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]),
@@ -163,6 +164,10 @@ let package = Package(
163164
// This package should never have dependencies – it exists to expose protocols implemented in WordPressCore
164165
// to UI code, because `wordpress-rs` doesn't work nicely with previews.
165166
]),
167+
.target(name: "WordPressIntelligence", dependencies: [
168+
"WordPressShared",
169+
.product(name: "SwiftSoup", package: "SwiftSoup"),
170+
]),
166171
.target(name: "WordPressLegacy", dependencies: ["DesignSystem", "WordPressShared"]),
167172
.target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]),
168173
.target(
@@ -251,6 +256,7 @@ let package = Package(
251256
.testTarget(name: "WordPressSharedObjCTests", dependencies: [.target(name: "WordPressShared"), .target(name: "WordPressTesting")], swiftSettings: [.swiftLanguageMode(.v5)]),
252257
.testTarget(name: "WordPressUIUnitTests", dependencies: [.target(name: "WordPressUI")], swiftSettings: [.swiftLanguageMode(.v5)]),
253258
.testTarget(name: "WordPressCoreTests", dependencies: [.target(name: "WordPressCore")]),
259+
.testTarget(name: "WordPressIntelligenceTests", dependencies: [.target(name: "WordPressIntelligence")])
254260
]
255261
)
256262

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Foundation
2+
import FoundationModels
3+
4+
/// Service for AI-powered content generation and analysis features.
5+
///
6+
/// This service provides tag suggestions, post summaries, excerpt generation,
7+
/// and other intelligence features using Foundation Models (iOS 26+).
8+
public actor IntelligenceService {
9+
/// Maximum context size for language model sessions (in tokens).
10+
///
11+
/// A single token corresponds to three or four characters in languages like
12+
/// English, Spanish, or German, and one token per character in languages like
13+
/// Japanese, Chinese, or Korean. In a single session, the sum of all tokens
14+
/// in the instructions, all prompts, and all outputs count toward the context window size.
15+
///
16+
/// https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models#Consider-context-size-limits-per-session
17+
public static let contextSizeLimit = 4096
18+
19+
/// Checks if intelligence features are supported on the current device.
20+
public nonisolated static var isSupported: Bool {
21+
guard #available(iOS 26, *) else {
22+
return false
23+
}
24+
switch SystemLanguageModel.default.availability {
25+
case .available:
26+
return true
27+
case .unavailable(let reason):
28+
switch reason {
29+
case .appleIntelligenceNotEnabled, .modelNotReady:
30+
return true
31+
case .deviceNotEligible:
32+
return false
33+
@unknown default:
34+
return false
35+
}
36+
}
37+
}
38+
39+
public init() {}
40+
41+
// MARK: - Public API
42+
43+
/// Suggests tags for a WordPress post.
44+
@available(iOS 26, *)
45+
public func suggestTags(post: String, siteTags: [String] = [], postTags: [String] = []) async throws -> [String] {
46+
try await TagSuggestion().generate(post: post, siteTags: siteTags, postTags: postTags)
47+
}
48+
49+
/// Summarizes a support ticket to a short title.
50+
@available(iOS 26, *)
51+
public func summarizeSupportTicket(content: String) async throws -> String {
52+
try await SupportTicketSummary.execute(content: content)
53+
}
54+
55+
/// Extracts relevant text from post content (removes HTML, limits size).
56+
public nonisolated func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String {
57+
Self.extractRelevantText(from: post, ratio: ratio)
58+
}
59+
60+
// MARK: - Shared Utilities
61+
62+
/// Extracts relevant text from post content, removing HTML and limiting size.
63+
public nonisolated static func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String {
64+
let extract = try? ContentExtractor.extractRelevantText(from: post)
65+
let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio
66+
return String((extract ?? post).prefix(Int(postSizeLimit)))
67+
}
68+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Foundation
2+
import WordPressShared
3+
4+
/// Target length for generated text.
5+
///
6+
/// Ranges are calibrated for English and account for cross-language variance.
7+
/// Sentences are the primary indicator; word counts accommodate language differences.
8+
///
9+
/// - **Short**: 1-2 sentences (15-35 words) - Social media, search snippets
10+
/// - **Medium**: 2-4 sentences (30-90 words) - RSS feeds, blog listings
11+
/// - **Long**: 5-7 sentences (90-130 words) - Detailed previews, newsletters
12+
///
13+
/// Word ranges are intentionally wide (2-2.3x) to handle differences in language
14+
/// structure (German compounds, Romance wordiness, CJK tokenization).
15+
public enum ContentLength: Int, CaseIterable, Sendable {
16+
case short
17+
case medium
18+
case long
19+
20+
public var displayName: String {
21+
switch self {
22+
case .short:
23+
AppLocalizedString("generation.length.short", value: "Short", comment: "Generated content length (needs to be short)")
24+
case .medium:
25+
AppLocalizedString("generation.length.medium", value: "Medium", comment: "Generated content length (needs to be short)")
26+
case .long:
27+
AppLocalizedString("generation.length.long", value: "Long", comment: "Generated content length (needs to be short)")
28+
}
29+
}
30+
31+
public var trackingName: String {
32+
switch self {
33+
case .short: "short"
34+
case .medium: "medium"
35+
case .long: "long"
36+
}
37+
}
38+
39+
public var promptModifier: String {
40+
"\(sentenceRange.lowerBound)-\(sentenceRange.upperBound) sentences (\(wordRange.lowerBound)-\(wordRange.upperBound) words)"
41+
}
42+
43+
public var sentenceRange: ClosedRange<Int> {
44+
switch self {
45+
case .short: 1...2
46+
case .medium: 2...4
47+
case .long: 5...7
48+
}
49+
}
50+
51+
public var wordRange: ClosedRange<Int> {
52+
switch self {
53+
case .short: 15...35
54+
case .medium: 40...80
55+
case .long: 90...130
56+
}
57+
}
58+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
import WordPressShared
3+
4+
/// Writing style for generated text.
5+
public enum WritingStyle: String, CaseIterable, Sendable {
6+
case engaging
7+
case conversational
8+
case witty
9+
case formal
10+
case professional
11+
12+
public var displayName: String {
13+
switch self {
14+
case .engaging:
15+
AppLocalizedString("generation.style.engaging", value: "Engaging", comment: "AI generation style")
16+
case .conversational:
17+
AppLocalizedString("generation.style.conversational", value: "Conversational", comment: "AI generation style")
18+
case .witty:
19+
AppLocalizedString("generation.style.witty", value: "Witty", comment: "AI generation style")
20+
case .formal:
21+
AppLocalizedString("generation.style.formal", value: "Formal", comment: "AI generation style")
22+
case .professional:
23+
AppLocalizedString("generation.style.professional", value: "Professional", comment: "AI generation style")
24+
}
25+
}
26+
27+
var promptModifier: String {
28+
"\(rawValue) (\(promptModifierDetails))"
29+
}
30+
31+
var promptModifierDetails: String {
32+
switch self {
33+
case .engaging: "engaging and compelling tone"
34+
case .witty: "witty, creative, entertaining"
35+
case .conversational: "friendly and conversational tone"
36+
case .formal: "formal and academic tone"
37+
case .professional: "professional and polished tone"
38+
}
39+
}
40+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import Foundation
2+
import FoundationModels
3+
4+
/// Excerpt generation for WordPress posts.
5+
///
6+
/// Generates multiple excerpt variations for blog posts with customizable
7+
/// length and writing style. Supports session-based usage (for UI with continuity)
8+
/// and one-shot generation (for tests and background tasks).
9+
@available(iOS 26, *)
10+
public struct ExcerptGeneration {
11+
public var length: ContentLength
12+
public var style: WritingStyle
13+
public var options: GenerationOptions
14+
15+
public init(
16+
length: ContentLength,
17+
style: WritingStyle,
18+
options: GenerationOptions = GenerationOptions(temperature: 0.7)
19+
) {
20+
self.length = length
21+
self.style = style
22+
self.options = options
23+
}
24+
25+
/// Generates excerpts with this configuration.
26+
public func generate(for content: String) async throws -> [String] {
27+
let content = IntelligenceService.extractRelevantText(from: content)
28+
let response = try await makeSession().respond(
29+
to: makePrompt(content: content),
30+
generating: Result.self,
31+
options: options
32+
)
33+
return response.content.excerpts
34+
}
35+
36+
/// Creates a language model session configured for excerpt generation.
37+
public func makeSession() -> LanguageModelSession {
38+
LanguageModelSession(
39+
model: .init(guardrails: .permissiveContentTransformations),
40+
instructions: Self.instructions
41+
)
42+
}
43+
44+
/// Instructions for the language model session.
45+
public static var instructions: String {
46+
"""
47+
You are helping a WordPress user generate an excerpt for their post or page.
48+
49+
**Prompt Parameters**
50+
- POST_CONTENT: contents of the post (HTML or plain text)
51+
- TARGET_LENGTH: MANDATORY sentence count (primary) and word count (secondary) for each excerpt
52+
- GENERATION_STYLE: the writing style to follow
53+
54+
\(PromptHelper.makeLocaleInstructions())
55+
56+
**CRITICAL Requirements (MUST be followed exactly)**
57+
1. ⚠️ LANGUAGE: Generate excerpts in the SAME language as POST_CONTENT. NO translation. NO defaulting to English. Match input language EXACTLY.
58+
59+
2. ⚠️ LENGTH: Each excerpt MUST match the TARGET_LENGTH specification.
60+
- PRIMARY: Match the sentence count (e.g., "1-2 sentences" means write 1 or 2 complete sentences)
61+
- SECONDARY: Stay within the word count range (accommodates language differences)
62+
- Write complete sentences only. Count sentences after writing.
63+
- VERIFY both sentence and word counts before responding.
64+
65+
3. ⚠️ STYLE: Follow the GENERATION_STYLE exactly (witty, professional, engaging, etc.)
66+
67+
**Excerpt best practices**
68+
- Follow WordPress ecosystem best practices for post excerpts
69+
- Include the post's main value proposition
70+
- Use active voice (avoid "is", "are", "was", "were" when possible)
71+
- End with implicit promise of more information (no ellipsis)
72+
- Include strategic keywords naturally
73+
- Write independently from the introduction – don't duplicate the opening paragraph
74+
- Make excerpts work as standalone copy for search results, social media, and email
75+
"""
76+
}
77+
78+
/// Creates a prompt for this excerpt configuration.
79+
public func makePrompt(content: String) -> String {
80+
"""
81+
Generate EXACTLY 3 different excerpts for the given post.
82+
83+
TARGET_LENGTH: \(length.promptModifier)
84+
CRITICAL: Write \(length.sentenceRange.lowerBound)-\(length.sentenceRange.upperBound) complete sentences. Stay within \(length.wordRange.lowerBound)-\(length.wordRange.upperBound) words.
85+
86+
GENERATION_STYLE: \(style.promptModifier)
87+
88+
POST_CONTENT:
89+
\(content)
90+
"""
91+
}
92+
93+
/// Prompt for generating additional excerpt options.
94+
public static var loadMorePrompt: String {
95+
"Generate 3 additional excerpts following the same TARGET_LENGTH and GENERATION_STYLE requirements"
96+
}
97+
98+
// MARK: - Result Type
99+
100+
@Generable
101+
public struct Result {
102+
@Guide(description: "Suggested post excerpts", .count(3))
103+
public var excerpts: [String]
104+
}
105+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import Foundation
2+
import FoundationModels
3+
4+
/// Post summarization for WordPress content.
5+
///
6+
/// Generates concise summaries that capture the main points and key information
7+
/// from WordPress post content in the same language as the source.
8+
///
9+
/// Example usage:
10+
/// ```swift
11+
/// let summary = PostSummary()
12+
/// let result = try await summary.generate(content: postContent)
13+
/// ```
14+
@available(iOS 26, *)
15+
public struct PostSummary {
16+
public var options: GenerationOptions
17+
18+
public init(options: GenerationOptions = GenerationOptions(temperature: 0.3)) {
19+
self.options = options
20+
}
21+
22+
/// Generate a summary for the given post content.
23+
///
24+
/// - Parameter content: The post content to summarize (HTML or plain text)
25+
/// - Returns: A concise summary in the same language as the source
26+
/// - Throws: If the language model session fails
27+
public func generate(content: String) async throws -> String {
28+
let session = makeSession()
29+
let prompt = makePrompt(content: content)
30+
return try await session.respond(to: prompt).content
31+
}
32+
33+
/// Creates a language model session configured for post summarization.
34+
///
35+
/// - Returns: Configured session with instructions
36+
public func makeSession() -> LanguageModelSession {
37+
LanguageModelSession(
38+
model: .init(guardrails: .permissiveContentTransformations),
39+
instructions: Self.instructions
40+
)
41+
}
42+
43+
/// Instructions for the language model on how to generate summaries.
44+
public static var instructions: String {
45+
"""
46+
You are helping a WordPress user understand the content of a post.
47+
Generate a concise summary that captures the main points and key information.
48+
The summary should be clear, informative, and written in a neutral tone.
49+
50+
\(PromptHelper.makeLocaleInstructions())
51+
52+
Do not include anything other than the summary in the response.
53+
"""
54+
}
55+
56+
/// Builds the prompt for summarizing post content.
57+
///
58+
/// - Parameter content: The post content to summarize
59+
/// - Returns: Formatted prompt string
60+
public func makePrompt(content: String) -> String {
61+
let extractedContent = IntelligenceService.extractRelevantText(from: content, ratio: 0.8)
62+
63+
return """
64+
Summarize the following post:
65+
66+
\(extractedContent)
67+
"""
68+
}
69+
}
70+
71+
@available(iOS 26, *)
72+
extension IntelligenceService {
73+
/// Post summarization for WordPress content.
74+
///
75+
/// - Parameter content: The post content to summarize
76+
/// - Returns: A concise summary
77+
/// - Throws: If summarization fails
78+
public func summarize(content: String) async throws -> String {
79+
try await PostSummary().generate(content: content)
80+
}
81+
}

0 commit comments

Comments
 (0)