Skip to content

Commit a45969e

Browse files
committed
Add support for more locales
1 parent 349e4f6 commit a45969e

31 files changed

+2752
-369
lines changed

Modules/Package.swift

Lines changed: 7 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

@@ -348,6 +354,7 @@ enum XcodeSupport {
348354
"ShareExtensionCore",
349355
"Support",
350356
"WordPressFlux",
357+
"WordPressIntelligence",
351358
"WordPressShared",
352359
"WordPressLegacy",
353360
"WordPressReader",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Foundation
2+
import FoundationModels
3+
import NaturalLanguage
4+
5+
public enum IntelligenceService {
6+
/// Maximum context size for language model sessions (in tokens).
7+
///
8+
/// A single token corresponds to three or four characters in languages like
9+
/// English, Spanish, or German, and one token per character in languages like
10+
/// Japanese, Chinese, or Korean. In a single session, the sum of all tokens
11+
/// in the instructions, all prompts, and all outputs count toward the context window size.
12+
///
13+
/// https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models#Consider-context-size-limits-per-session
14+
public static let contextSizeLimit = 4096
15+
16+
/// Checks if intelligence features are supported on the current device.
17+
public nonisolated static var isSupported: Bool {
18+
guard #available(iOS 26, *) else {
19+
return false
20+
}
21+
switch SystemLanguageModel.default.availability {
22+
case .available:
23+
return true
24+
case .unavailable(let reason):
25+
switch reason {
26+
case .appleIntelligenceNotEnabled, .modelNotReady:
27+
return true
28+
case .deviceNotEligible:
29+
return false
30+
@unknown default:
31+
return false
32+
}
33+
}
34+
}
35+
36+
/// Extracts relevant text from post content, removing HTML and limiting size.
37+
public static func extractRelevantText(from post: String, ratio: CGFloat = 0.6) -> String {
38+
let extract = try? ContentExtractor.extractRelevantText(from: post)
39+
let postSizeLimit = Double(IntelligenceService.contextSizeLimit) * ratio
40+
return String((extract ?? post).prefix(Int(postSizeLimit)))
41+
}
42+
43+
/// - 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
44+
static func makeLocaleInstructions(for locale: Locale = Locale.current) -> String {
45+
if Locale.Language(identifier: "en_US").isEquivalent(to: locale.language) {
46+
return "" // Skip the locale phrase for U.S. English.
47+
}
48+
return "The person's locale is \(locale.identifier)."
49+
}
50+
51+
/// Detects the dominant language of the given text.
52+
///
53+
/// - Parameter text: The text to analyze
54+
/// - Returns: The detected language code (e.g., "en", "es", "fr", "ja"), or nil if detection fails
55+
public static func detectLanguage(from text: String) -> String? {
56+
let recognizer = NLLanguageRecognizer()
57+
recognizer.processString(text)
58+
59+
guard let languageCode = recognizer.dominantLanguage else {
60+
return nil
61+
}
62+
63+
return languageCode.rawValue
64+
}
65+
}
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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 PostExcerptGenerator {
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 prompt = await makePrompt(content: content)
28+
let response = try await makeSession().respond(
29+
to: prompt,
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+
**Parameters**
50+
- POST_CONTENT: post contents (HTML or plain text)
51+
- TARGET_LANGUAGE: detected language code (e.g., "en", "es", "fr", "ja")
52+
- TARGET_LENGTH: sentence count (primary) and word range (secondary)
53+
- GENERATION_STYLE: writing style to apply
54+
55+
\(IntelligenceService.makeLocaleInstructions())
56+
57+
**Requirements**
58+
1. ⚠️ LANGUAGE: Match TARGET_LANGUAGE code if provided, otherwise match POST_CONTENT language. Never translate or default to English.
59+
60+
2. ⚠️ LENGTH: Match TARGET_LENGTH sentence count, stay within word range. Write complete sentences only.
61+
62+
3. ⚠️ STYLE: Follow GENERATION_STYLE exactly.
63+
64+
**Best Practices**
65+
- Capture the post's main value proposition
66+
- Use active voice and strategic keywords naturally
67+
- Don't duplicate the opening paragraph
68+
- Work as standalone copy for search results, social media, and email
69+
"""
70+
}
71+
72+
/// Creates a prompt for this excerpt configuration.
73+
///
74+
/// This method handles content extraction (removing HTML, limiting size) and language detection
75+
/// automatically before creating the prompt.
76+
///
77+
/// - Parameter content: The raw post content (may include HTML)
78+
/// - Returns: The formatted prompt ready for the language model
79+
public func makePrompt(content: String) async -> String {
80+
let extractedContent = IntelligenceService.extractRelevantText(from: content)
81+
let language = IntelligenceService.detectLanguage(from: extractedContent)
82+
let languageInstruction = language.map { "TARGET_LANGUAGE: \($0)\n" } ?? ""
83+
84+
return """
85+
Generate EXACTLY 3 different excerpts for the given post.
86+
87+
\(languageInstruction)TARGET_LENGTH: \(length.promptModifier)
88+
CRITICAL: Write \(length.sentenceRange.lowerBound)-\(length.sentenceRange.upperBound) complete sentences. Stay within \(length.wordRange.lowerBound)-\(length.wordRange.upperBound) words.
89+
90+
GENERATION_STYLE: \(style.promptModifier)
91+
92+
POST_CONTENT:
93+
\(extractedContent)
94+
"""
95+
}
96+
97+
/// Prompt for generating additional excerpt options.
98+
public static var loadMorePrompt: String {
99+
"Generate 3 additional excerpts following the same TARGET_LENGTH and GENERATION_STYLE requirements"
100+
}
101+
102+
// MARK: - Result Type
103+
104+
@Generable
105+
public struct Result {
106+
@Guide(description: "Suggested post excerpts", .count(3))
107+
public var excerpts: [String]
108+
}
109+
}

0 commit comments

Comments
 (0)