Skip to content

Commit 99c6674

Browse files
authored
Implement CMM-739: Generate post excerpt (#24852)
* Implement CMM-739: Generate post excerpt * Simplify debounce * Improve prompts * Fix open settings * Fix iPad presentation * Add analytics * Add analytics * Remove redundant canImport * Extract generateExcerptInstructions * Add show more button * Remove TestsScenario * Remove chevron * Remove test scenarios * Fix isGenerating not resettting * Fix error not shown * Add better error messags * Enable permissiveContentTransformations * Fix release build * Disable flaky tests
1 parent 45e61b1 commit 99c6674

File tree

12 files changed

+1046
-97
lines changed

12 files changed

+1046
-97
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import Foundation
2+
import FoundationModels
3+
4+
public enum LanguageModelHelper {
5+
public static var isSupported: Bool {
6+
guard #available(iOS 26, *) else { return false }
7+
switch SystemLanguageModel.default.availability {
8+
case .available:
9+
return true
10+
case .unavailable(let reason):
11+
switch reason {
12+
case .appleIntelligenceNotEnabled, .modelNotReady:
13+
return true
14+
case .deviceNotEligible:
15+
return false
16+
@unknown default:
17+
return false
18+
}
19+
}
20+
}
21+
22+
public static var generateExcerptInstructions: String {
23+
"""
24+
Generate exactly 3 excerpts for the blog post and follow the instructions from the prompt regarding the length and the style.
25+
26+
CRITICAL CONSTRAINTS:
27+
• Each excerpt MUST follow the style and the length requirements
28+
29+
EXCERPT BEST PRACTICES:
30+
* Follow the best practices for post excerpts esteblished in the WordPress ecosystem
31+
• Include the post's main value proposition
32+
• Use active voice (avoid "is", "are", "was", "were" when possible)
33+
• End with implicit promise of more information
34+
• Do not use ellipsis (...) at the end
35+
* Focus on value, not summary
36+
* Include strategic keywords naturall
37+
* 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.
38+
39+
VARIATION GUIDELINES:
40+
Excerpt 1: Open with a question that addresses reader's problem
41+
Excerpt 2: Start with a bold statement or surprising fact
42+
Excerpt 3: Lead with the primary benefit or outcome
43+
"""
44+
}
45+
46+
public static func makeGenerateExcerptPrompt(
47+
content: String,
48+
length: GeneratedContentLength,
49+
style: GenerationStyle
50+
) -> String {
51+
"""
52+
Generate excerpts with the following constraints (MUST FOLLOW):
53+
54+
• Length: \(length.promptModifier)
55+
• Style: \(style.promptModifier)
56+
57+
SOURCE POST CONTENT:
58+
\(content)
59+
"""
60+
}
61+
62+
public static var generateMoreOptionsPrompt: String {
63+
"Generate additional three options"
64+
}
65+
}
66+
67+
public enum GenerationStyle: String, CaseIterable, RawRepresentable {
68+
case engaging
69+
case conversational
70+
case witty
71+
case formal
72+
case professional
73+
74+
public var displayName: String {
75+
switch self {
76+
case .engaging:
77+
NSLocalizedString("generation.style.engaging", value: "Engaging", comment: "AI generation style")
78+
case .conversational:
79+
NSLocalizedString("generation.style.conversational", value: "Conversational", comment: "AI generation style")
80+
case .witty:
81+
NSLocalizedString("generation.style.witty", value: "Witty", comment: "AI generation style")
82+
case .formal:
83+
NSLocalizedString("generation.style.formal", value: "Formal", comment: "AI generation style")
84+
case .professional:
85+
NSLocalizedString("generation.style.professional", value: "Professional", comment: "AI generation style")
86+
}
87+
}
88+
89+
public var promptModifier: String {
90+
"\(rawValue) (\(promptModifierDetails))"
91+
}
92+
93+
var promptModifierDetails: String {
94+
switch self {
95+
case .engaging: "engaging and compelling tone"
96+
case .witty: "witty, creative, entertaining"
97+
case .conversational: "friendly and conversational tone"
98+
case .formal: "formal and academic tone"
99+
case .professional: "professional and polished tone"
100+
}
101+
}
102+
}
103+
104+
public enum GeneratedContentLength: Int, CaseIterable, RawRepresentable {
105+
case short
106+
case medium
107+
case long
108+
109+
public var displayName: String {
110+
switch self {
111+
case .short:
112+
NSLocalizedString("generation.length.short", value: "Short", comment: "Generated content length (needs to be short)")
113+
case .medium:
114+
NSLocalizedString("generation.length.medium", value: "Medium", comment: "Generated content length (needs to be short)")
115+
case .long:
116+
NSLocalizedString("generation.length.long", value: "Long", comment: "Generated content length (needs to be short)")
117+
}
118+
}
119+
120+
public var trackingName: String { name }
121+
122+
public var promptModifier: String {
123+
"\(wordRange) words"
124+
}
125+
126+
private var name: String {
127+
switch self {
128+
case .short: "short"
129+
case .medium: "medium"
130+
case .long: "long"
131+
}
132+
}
133+
134+
private var wordRange: String {
135+
switch self {
136+
case .short: "20-40"
137+
case .medium: "50-70"
138+
case .long: "120-180"
139+
}
140+
}
141+
}

Modules/Sources/WordPressUI/Views/EmptyStateView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public struct EmptyStateView<Label: View, Description: View, Actions: View>: Vie
2626
label()
2727
.font(.title2.weight(.medium))
2828
.labelStyle(EmptyStateViewLabelStyle())
29+
.multilineTextAlignment(.center)
2930
description()
3031
.font(.subheadline)
3132
.multilineTextAlignment(.center)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import SwiftUI
2+
import FoundationModels
3+
4+
@available(iOS 26, *)
5+
public struct LanguageModelUnavailableView: View {
6+
public let reason: SystemLanguageModel.Availability.UnavailableReason
7+
8+
public var body: some View {
9+
makeUnavailableView(for: reason)
10+
}
11+
12+
public init(reason: SystemLanguageModel.Availability.UnavailableReason) {
13+
self.reason = reason
14+
}
15+
16+
@ViewBuilder
17+
private func makeUnavailableView(for reason: SystemLanguageModel.Availability.UnavailableReason) -> some View {
18+
switch reason {
19+
case .appleIntelligenceNotEnabled:
20+
EmptyStateView {
21+
Label(Strings.appleIntelligenceDisabledTitle, systemImage: "apple.intelligence")
22+
} description: {
23+
Text(Strings.appleIntelligenceDisabledMessage)
24+
} actions: {
25+
if let settingURL = URL(string: UIApplication.openSettingsURLString) {
26+
Button(Strings.openAppleIntelligenceSettings) {
27+
UIApplication.shared.open(settingURL)
28+
}
29+
.buttonStyle(.borderedProminent)
30+
.tint(AppColor.primary)
31+
}
32+
}
33+
case .modelNotReady:
34+
EmptyStateView {
35+
Label(Strings.preparingModel, systemImage: "apple.intelligence")
36+
} description: {
37+
Text(Strings.preparingModelDescription)
38+
} actions: {
39+
EmptyView()
40+
}
41+
default:
42+
EmptyStateView {
43+
Label(Strings.appleIntelligenceUnavailableTitle, systemImage: "apple.intelligence")
44+
} description: {
45+
Text(Strings.appleIntelligenceUnavailableTitle)
46+
} actions: {
47+
EmptyView()
48+
}
49+
}
50+
}
51+
}
52+
53+
@available(iOS 26, *)
54+
#Preview {
55+
LanguageModelUnavailableView(reason: .appleIntelligenceNotEnabled)
56+
}
57+
58+
private enum Strings {
59+
static let appleIntelligenceDisabledTitle = NSLocalizedString(
60+
"intelligence.unavailableView.appleIntelligenceDisabled.title",
61+
value: "Apple Intelligence Required",
62+
comment: "Title shown when Apple Intelligence is disabled"
63+
)
64+
65+
static let appleIntelligenceDisabledMessage = NSLocalizedString(
66+
"intelligence.unavailableView.appleIntelligenceDisabled.message",
67+
value: "To generate excerpts with AI, please enable Apple Intelligence in Settings. This feature uses on-device processing to protect your privacy.",
68+
comment: "Message shown when Apple Intelligence is disabled"
69+
)
70+
71+
static let openAppleIntelligenceSettings = NSLocalizedString(
72+
"intelligence.unavailableView.appleIntelligenceDisabled.openSettings",
73+
value: "Open Settings",
74+
comment: "Button to open Apple Intelligence settings"
75+
)
76+
77+
static let preparingModel = NSLocalizedString(
78+
"intelligence.unavailableView.preparingModel.title",
79+
value: "Preparing model...",
80+
comment: "Title shown when the AI model is not ready"
81+
)
82+
83+
static let preparingModelDescription = NSLocalizedString(
84+
"intelligence.unavailableView.preparingModel.description",
85+
value: "The AI model is downloading or being prepared. Please try again in a moment.",
86+
comment: "Description shown when the AI model is not ready"
87+
)
88+
89+
static let appleIntelligenceUnavailableTitle = NSLocalizedString(
90+
"intelligence.unavailableView.appleIntelligenceUnvailable.title",
91+
value: "Apple Intelligence Unvailable",
92+
comment: "Title shown when Apple Intelligence is unavailable"
93+
)
94+
95+
static let appleIntelligenceUnavailableMessage = NSLocalizedString(
96+
"intelligence.unavailableView.appleIntelligenceUnvailable.message",
97+
value: "Apple Intelligence is not available on this device",
98+
comment: "Message shown when Apple Intelligence is unavailable"
99+
)
100+
}

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
26.4
22
-----
3+
* [*] [Intelligence] Add support for generating excerpts for posts [#24852]
34

45
26.3.1
56
------

Tests/KeystoneTests/Tests/Services/AccountSettingsServiceTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ class AccountSettingsServiceTests: CoreDataTestCase {
6262
}
6363

6464
func testCancelGettingSettings() throws {
65+
XCTSkip("Flaky")
66+
6567
// This test performs steps described in the link below to reproduce a crash.
6668
// https://github.com/wordpress-mobile/WordPress-iOS/issues/20379#issuecomment-1481995663
6769

WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import Foundation
22
import WordPressData
33
import WordPressShared
44

5-
// WPiOS-only events
65
@objc public enum WPAnalyticsEvent: Int {
76

87
case createSheetShown
@@ -669,6 +668,12 @@ import WordPressShared
669668
case jetpackConnectCompleted
670669
case jetpackConnectStepRetried
671670

671+
// Intelligence
672+
case intelligenceExcerptGeneratorOpened
673+
case intelligenceExcerptSelected
674+
case intelligenceExcerptOptionsGenerated
675+
case intelligenceUnavailableViewShown
676+
672677
/// A String that represents the event
673678
var value: String {
674679
switch self {
@@ -1808,6 +1813,16 @@ import WordPressShared
18081813
return "jetpack_rest_connect_completed"
18091814
case .jetpackConnectStepRetried:
18101815
return "jetpack_rest_connect_step_retried"
1816+
1817+
// Intelligence
1818+
case .intelligenceExcerptGeneratorOpened:
1819+
return "intelligence_excerpt_generator_opened"
1820+
case .intelligenceExcerptSelected:
1821+
return "intelligence_excerpt_selected"
1822+
case .intelligenceExcerptOptionsGenerated:
1823+
return "intelligence_excerpt_options_generated"
1824+
case .intelligenceUnavailableViewShown:
1825+
return "intelligence_unavailable_view_shown"
18111826
} // END OF SWITCH
18121827
}
18131828

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import SwiftUI
2+
3+
extension Button where Label == Text {
4+
@ViewBuilder
5+
public static func make(role: BackportButtonRole, _ action: @escaping () -> Void) -> some View {
6+
if #available(iOS 26, *) {
7+
SwiftUI.Button(role: ButtonRole(role), action: action)
8+
} else {
9+
SwiftUI.Button(role.title) {
10+
action()
11+
}
12+
}
13+
}
14+
}
15+
16+
public enum BackportButtonRole {
17+
case cancel
18+
case close
19+
case confirm
20+
21+
var title: String {
22+
switch self {
23+
case .cancel: SharedStrings.Button.cancel
24+
case .close: SharedStrings.Button.close
25+
case .confirm: SharedStrings.Button.done
26+
}
27+
}
28+
}
29+
30+
@available(iOS 26, *)
31+
private extension ButtonRole {
32+
init(_ role: BackportButtonRole) {
33+
switch role {
34+
case .cancel: self = .cancel
35+
case .close: self = .close
36+
case .confirm: self = .confirm
37+
}
38+
}
39+
}

WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,11 @@ private struct PostSettingsView: View {
182182
private var excerptSection: some View {
183183
Section {
184184
NavigationLink {
185-
PostSettingsExcerptEditor(text: $viewModel.settings.excerpt)
186-
.navigationTitle(Strings.excerptHeader)
185+
PostSettingsExcerptEditor(
186+
postContent: (viewModel.post.content ?? ""),
187+
text: $viewModel.settings.excerpt
188+
)
189+
.navigationTitle(Strings.excerptHeader)
187190
} label: {
188191
PostSettingExcerptRow(text: viewModel.settings.excerpt)
189192
}

0 commit comments

Comments
 (0)