Skip to content

Commit 64ff5bd

Browse files
authored
Add tag page experiment and pull storylines content (#28463)
* Add Storylines content model, fetch it from s3 and provide to DotcomTagPagesRenderingDataModel * Include this behind a 0% experiment
1 parent ed5698a commit 64ff5bd

File tree

3 files changed

+180
-1
lines changed

3 files changed

+180
-1
lines changed

common/app/experiments/Experiments.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ object ActiveExperiments extends ExperimentsDefinition {
1414
StarRatingRedesign,
1515
DarkModeWeb,
1616
GoogleOneTap,
17+
TagPageStorylines,
1718
)
1819
implicit val canCheckExperiment: CanCheckExperiment = new CanCheckExperiment(this)
1920
}
@@ -44,3 +45,12 @@ object DarkModeWeb
4445
sellByDate = LocalDate.of(2026, 1, 30),
4546
participationGroup = Perc0D,
4647
)
48+
49+
object TagPageStorylines
50+
extends Experiment(
51+
name = "tag-page-storylines",
52+
description = "Enable AI storylines content on web tag pages",
53+
owners = Seq(Owner.withEmail("[email protected]")),
54+
sellByDate = LocalDate.of(2026, 1, 30),
55+
participationGroup = Perc0E,
56+
)
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package model
2+
3+
import common.GuLogging
4+
import conf.Configuration
5+
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
6+
import io.circe.{Decoder, DecodingFailure, Encoder}
7+
import play.api.libs.json.{Json, Writes}
8+
import services.S3
9+
10+
import java.time.Instant
11+
import experiments.{TagPageStorylines, ActiveExperiments}
12+
import play.api.mvc.{Filter, RequestHeader}
13+
14+
// this mirrors the structure in the tool generating the content
15+
// https://github.com/guardian/tag-page-supercharger/blob/main/app/models/FrontendContent.scala#L18
16+
case class StorylinesContent(
17+
created: Instant,
18+
tag: String,
19+
storylines: List[Storyline],
20+
earliestArticleTime: Option[Instant],
21+
latestArticleTime: Option[Instant],
22+
)
23+
24+
case class Storyline(
25+
title: String,
26+
content: List[CategoryContent],
27+
)
28+
29+
case class CategoryContent(
30+
category: String,
31+
articles: Option[List[ArticleData]],
32+
)
33+
34+
case class ImageData(
35+
src: Option[String],
36+
altText: Option[String],
37+
isAvatar: Boolean = false,
38+
mediaData: Option[StorylinesMediaData] = None,
39+
)
40+
41+
case class ArticleData(
42+
url: String,
43+
headline: String,
44+
byline: Option[String],
45+
publicationTime: Instant,
46+
image: Option[
47+
ImageData,
48+
],
49+
)
50+
51+
// These mirror the definitions from DCR: https://github.com/guardian/dotcom-rendering/blob/main/dotcom-rendering/src/types/mainMedia.ts
52+
// In DCR, we effectively bypass the usual transformations done to Frontend/CAPI data,
53+
// but need to provide the fields below so multimedia cards can render correctly.
54+
// There are existing Video/Gallery/Audio references in Frontend, so we define separate types here to avoid confusion.
55+
sealed trait StorylinesMediaData {
56+
def `type`: String
57+
}
58+
59+
case class StorylinesVideo(
60+
`type`: String = "YoutubeVideo",
61+
id: String,
62+
videoId: String,
63+
height: Int,
64+
width: Int,
65+
origin: String,
66+
title: String,
67+
duration: Int,
68+
expired: Boolean,
69+
image: Option[String] = None,
70+
) extends StorylinesMediaData
71+
72+
case class StorylinesAudio(
73+
`type`: String = "Audio",
74+
duration: String,
75+
) extends StorylinesMediaData
76+
77+
case class StorylinesGallery(
78+
`type`: String = "Gallery",
79+
count: String,
80+
) extends StorylinesMediaData
81+
82+
object StorylinesMediaData {
83+
implicit val videoDecoder: Decoder[StorylinesVideo] = deriveDecoder
84+
implicit val audioDecoder: Decoder[StorylinesAudio] = deriveDecoder
85+
implicit val galleryDecoder: Decoder[StorylinesGallery] = deriveDecoder
86+
implicit val mediaDataDecoder: Decoder[StorylinesMediaData] =
87+
Decoder.instance { cursor =>
88+
cursor.get[String]("type").flatMap {
89+
case "YoutubeVideo" => cursor.as[StorylinesVideo]
90+
case "Audio" => cursor.as[StorylinesAudio]
91+
case "Gallery" => cursor.as[StorylinesGallery]
92+
case other =>
93+
Left(DecodingFailure(s"Unknown mediaType: $other", cursor.history))
94+
}
95+
}
96+
97+
implicit val videoEncoder: Encoder[StorylinesVideo] = deriveEncoder
98+
implicit val audioEncoder: Encoder[StorylinesAudio] = deriveEncoder
99+
implicit val galleryEncoder: Encoder[StorylinesGallery] = deriveEncoder
100+
implicit val mediaDataEncoder: Encoder[StorylinesMediaData] = Encoder.instance {
101+
case v: StorylinesVideo => videoEncoder(v)
102+
case a: StorylinesAudio => audioEncoder(a)
103+
case g: StorylinesGallery => galleryEncoder(g)
104+
}
105+
106+
implicit val videoWrites: Writes[StorylinesVideo] = Json.writes[StorylinesVideo]
107+
implicit val audioWrites: Writes[StorylinesAudio] = Json.writes[StorylinesAudio]
108+
implicit val galleryWrites: Writes[StorylinesGallery] = Json.writes[StorylinesGallery]
109+
implicit val mediaDataWrites: Writes[StorylinesMediaData] = Json.writes[StorylinesMediaData]
110+
}
111+
112+
object Storyline {
113+
implicit val decoder: Decoder[Storyline] = deriveDecoder
114+
implicit val encoder: Encoder[Storyline] = deriveEncoder
115+
implicit val storylinesWrites: Writes[Storyline] = Json.writes[Storyline]
116+
117+
}
118+
119+
object CategoryContent {
120+
implicit val decoder: Decoder[CategoryContent] = deriveDecoder
121+
implicit val encoder: Encoder[CategoryContent] = deriveEncoder
122+
implicit val categoryContentWrites: Writes[CategoryContent] = Json.writes[CategoryContent]
123+
124+
}
125+
126+
object ImageData {
127+
implicit val decoder: Decoder[ImageData] = deriveDecoder
128+
implicit val encoder: Encoder[ImageData] = deriveEncoder
129+
implicit val imageDataWrites: Writes[ImageData] = Json.writes[ImageData]
130+
131+
}
132+
133+
object ArticleData {
134+
implicit val decoder: Decoder[ArticleData] = deriveDecoder
135+
implicit val encoder: Encoder[ArticleData] = deriveEncoder
136+
implicit val articleDataWrites: Writes[ArticleData] = Json.writes[ArticleData]
137+
138+
}
139+
140+
object StorylinesContent extends GuLogging {
141+
implicit val storylinesContentDecoder: Decoder[StorylinesContent] = deriveDecoder
142+
implicit val storylinesContentEncoder: Encoder[StorylinesContent] = deriveEncoder
143+
implicit val storylinesWrites: Writes[StorylinesContent] = Json.writes[StorylinesContent]
144+
145+
def getContent(tag: String)(implicit rh: RequestHeader): Option[StorylinesContent] = {
146+
if (ActiveExperiments.isParticipating(TagPageStorylines)) {
147+
lazy val stage: String = Configuration.facia.stage.toUpperCase
148+
val encodedTag = java.net.URLEncoder.encode(tag, "UTF-8")
149+
val location = s"$stage/tag-page-ai-data/$encodedTag.json"
150+
val maybeStorylinesContent = S3.get(location).map { jsonString =>
151+
io.circe.parser.decode[StorylinesContent](jsonString) match {
152+
case Right(content) => Some(content)
153+
case Left(error) =>
154+
log.error(s"Error decoding Storylines Content for tag $tag: $error")
155+
None
156+
}
157+
}
158+
maybeStorylinesContent match {
159+
case Some(content) => content
160+
case None => log.error("Storylines Content not found for tag: " + tag); None
161+
}
162+
} else {
163+
None
164+
}
165+
}
166+
}

common/app/model/dotcomrendering/DotcomTagPagesRenderingDataModel.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import common.Maps.RichMap
55
import common.commercial.EditionCommercialProperties
66
import conf.Configuration
77
import experiments.ActiveExperiments
8-
import model.Tags
8+
import model.{StorylinesContent, Tags}
99
import model.pressed.PressedContent
1010
import navigation.{FooterLinks, Nav}
1111
import org.joda.time.{DateTime, DateTimeZone}
@@ -37,6 +37,7 @@ case class DotcomTagPagesRenderingDataModel(
3737
isAdFreeUser: Boolean,
3838
canonicalUrl: String,
3939
contributionsServiceUrl: String,
40+
storylinesContent: Option[StorylinesContent],
4041
)
4142

4243
object DotcomTagPagesRenderingDataModel {
@@ -65,6 +66,7 @@ object DotcomTagPagesRenderingDataModel {
6566
"isAdFreeUser" -> model.isAdFreeUser,
6667
"canonicalUrl" -> model.canonicalUrl,
6768
"contributionsServiceUrl" -> model.contributionsServiceUrl,
69+
"storylinesContent" -> model.storylinesContent,
6870
)
6971
}
7072
}
@@ -131,6 +133,7 @@ object DotcomTagPagesRenderingDataModel {
131133
isAdFreeUser = views.support.Commercial.isAdFree(request),
132134
canonicalUrl = CanonicalLink(request, page.metadata.webUrl),
133135
contributionsServiceUrl = Configuration.contributionsService.url,
136+
storylinesContent = StorylinesContent.getContent(page.metadata.id)(request),
134137
)
135138
}
136139

0 commit comments

Comments
 (0)