Twitter Recommendation Algorithm

Please note we have force-pushed a new initial commit in order to remove some publicly-available Twitter user information. Note that this process may be required in the future.
This commit is contained in:
twitter-team
2023-03-31 17:36:31 -05:00
commit ef4c5eb65e
5364 changed files with 460239 additions and 0 deletions

View File

@ -0,0 +1,17 @@
target(
name = "adapter",
dependencies = ["timelineranker/common/src/main/scala/com/twitter/timelineranker/adapter"],
)
target(
name = "model",
dependencies = ["timelineranker/common/src/main/scala/com/twitter/timelineranker/model"],
)
target(
tags = ["bazel-compatible"],
dependencies = [
":adapter",
":model",
],
)

View File

@ -0,0 +1,6 @@
target(
tags = ["bazel-compatible"],
dependencies = [
"timelineranker/common/src/main/scala/com/twitter/timelineranker/model",
],
)

View File

@ -0,0 +1,14 @@
scala_library(
sources = ["*.scala"],
compiler_option_sets = ["fatal_warnings"],
platform = "java8",
tags = ["bazel-compatible"],
dependencies = [
"configapi/configapi-core",
"src/thrift/com/twitter/timelineservice/server/internal:thrift-scala",
"src/thrift/com/twitter/tweetypie:tweet-scala",
"timelineranker/common:model",
"timelines/src/main/scala/com/twitter/timelines/clientconfig",
"timelines/src/main/scala/com/twitter/timelines/model/tweet",
],
)

View File

@ -0,0 +1,139 @@
package com.twitter.timelineranker.adapter
import com.twitter.timelineranker.model._
import com.twitter.timelines.model.tweet.HydratedTweet
import com.twitter.timelines.model.TweetId
import com.twitter.timelineservice.model.TimelineId
import com.twitter.timelineservice.model.core
import com.twitter.timelineservice.{model => tls}
import com.twitter.timelineservice.{thriftscala => tlsthrift}
import com.twitter.timelineservice.model.core._
import com.twitter.util.Return
import com.twitter.util.Throw
import com.twitter.util.Try
/**
* Enables TLR model objects to be converted to/from TLS model/thrift objects.
*/
object TimelineServiceAdapter {
def toTlrQuery(
id: Long,
tlsRange: tls.TimelineRange,
getTweetsFromArchiveIndex: Boolean = true
): ReverseChronTimelineQuery = {
val timelineId = TimelineId(id, TimelineKind.home)
val maxCount = tlsRange.maxCount
val tweetIdRange = tlsRange.cursor.map { cursor =>
TweetIdRange(
fromId = cursor.tweetIdBounds.bottom,
toId = cursor.tweetIdBounds.top
)
}
val options = ReverseChronTimelineQueryOptions(
getTweetsFromArchiveIndex = getTweetsFromArchiveIndex
)
ReverseChronTimelineQuery(timelineId, Some(maxCount), tweetIdRange, Some(options))
}
def toTlsQuery(query: ReverseChronTimelineQuery): tls.TimelineQuery = {
val tlsRange = toTlsRange(query.range, query.maxCount)
tls.TimelineQuery(
id = query.id.id,
kind = query.id.kind,
range = tlsRange
)
}
def toTlsRange(range: Option[TimelineRange], maxCount: Option[Int]): tls.TimelineRange = {
val cursor = range.map {
case tweetIdRange: TweetIdRange =>
RequestCursor(
top = tweetIdRange.toId.map(CursorState.fromTweetId),
bottom = tweetIdRange.fromId.map(core.CursorState.fromTweetId)
)
case _ =>
throw new IllegalArgumentException(s"Only TweetIdRange is supported. Found: $range")
}
maxCount
.map { count => tls.TimelineRange(cursor, count) }
.getOrElse(tls.TimelineRange(cursor))
}
/**
* Converts TLS timeline to a Try of TLR timeline.
*
* TLS timeline not only contains timeline entries/attributes but also the retrieval state;
* whereas TLR timeline only has entries/attributes. Therefore, the TLS timeline is
* mapped to a Try[Timeline] where the Try part captures retrieval state and
* Timeline captures entries/attributes.
*/
def toTlrTimelineTry(tlsTimeline: tls.Timeline[tls.TimelineEntry]): Try[Timeline] = {
require(
tlsTimeline.kind == TimelineKind.home,
s"Only home timelines are supported. Found: ${tlsTimeline.kind}"
)
tlsTimeline.state match {
case Some(TimelineHit) | None =>
val tweetEnvelopes = tlsTimeline.entries.map {
case tweet: tls.Tweet =>
TimelineEntryEnvelope(Tweet(tweet.tweetId))
case entry =>
throw new Exception(s"Only tweet timelines are supported. Found: $entry")
}
Return(Timeline(TimelineId(tlsTimeline.id, tlsTimeline.kind), tweetEnvelopes))
case Some(TimelineNotFound) | Some(TimelineUnavailable) =>
Throw(new tls.core.TimelineUnavailableException(tlsTimeline.id, Some(tlsTimeline.kind)))
}
}
def toTlsTimeline(timeline: Timeline): tls.Timeline[tls.Tweet] = {
val entries = timeline.entries.map { entry =>
entry.entry match {
case tweet: Tweet => tls.Tweet(tweet.id)
case entry: HydratedTweetEntry => tls.Tweet.fromThrift(entry.tweet)
case _ =>
throw new IllegalArgumentException(
s"Only tweet timelines are supported. Found: ${entry.entry}"
)
}
}
tls.Timeline(
id = timeline.id.id,
kind = timeline.id.kind,
entries = entries
)
}
def toTweetIds(timeline: tlsthrift.Timeline): Seq[TweetId] = {
timeline.entries.map {
case tlsthrift.TimelineEntry.Tweet(tweet) =>
tweet.statusId
case entry =>
throw new IllegalArgumentException(s"Only tweet timelines are supported. Found: ${entry}")
}
}
def toTweetIds(timeline: Timeline): Seq[TweetId] = {
timeline.entries.map { entry =>
entry.entry match {
case tweet: Tweet => tweet.id
case entry: HydratedTweetEntry => entry.tweet.id
case _ =>
throw new IllegalArgumentException(
s"Only tweet timelines are supported. Found: ${entry.entry}"
)
}
}
}
def toHydratedTweets(timeline: Timeline): Seq[HydratedTweet] = {
timeline.entries.map { entry =>
entry.entry match {
case hydratedTweet: HydratedTweet => hydratedTweet
case _ =>
throw new IllegalArgumentException(s"Expected hydrated tweet. Found: ${entry.entry}")
}
}
}
}

View File

@ -0,0 +1,23 @@
scala_library(
sources = ["*.scala"],
compiler_option_sets = ["fatal_warnings"],
platform = "java8",
tags = ["bazel-compatible"],
dependencies = [
"src/java/com/twitter/common/text/language:locale-util",
"src/thrift/com/twitter/search:earlybird-scala",
"src/thrift/com/twitter/search/common:features-scala",
"src/thrift/com/twitter/timelineranker/server/model:thrift-scala",
"timelines:config-api-base",
"timelines/src/main/scala/com/twitter/timelines/common/model",
"timelines/src/main/scala/com/twitter/timelines/earlybird/common/options",
"timelines/src/main/scala/com/twitter/timelines/earlybird/common/utils",
"timelines/src/main/scala/com/twitter/timelines/model/candidate",
"timelines/src/main/scala/com/twitter/timelines/model/tweet",
"timelines/src/main/scala/com/twitter/timelines/util",
"timelineservice/common/src/main/scala/com/twitter/timelineservice/model",
],
exports = [
"timelines:config-api-base",
],
)

View File

@ -0,0 +1,35 @@
package com.twitter.timelineranker.model
import com.twitter.search.common.features.thriftscala.ThriftTweetFeatures
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.model.tweet.HydratedTweet
import com.twitter.tweetypie.thriftscala
object CandidateTweet {
val DefaultFeatures: ThriftTweetFeatures = ThriftTweetFeatures()
def fromThrift(candidate: thrift.CandidateTweet): CandidateTweet = {
val tweet: thriftscala.Tweet = candidate.tweet.getOrElse(
throw new IllegalArgumentException(s"CandidateTweet.tweet must have a value")
)
val features = candidate.features.getOrElse(
throw new IllegalArgumentException(s"CandidateTweet.features must have a value")
)
CandidateTweet(HydratedTweet(tweet), features)
}
}
/**
* A candidate Tweet and associated information.
* Model object for CandidateTweet thrift struct.
*/
case class CandidateTweet(hydratedTweet: HydratedTweet, features: ThriftTweetFeatures) {
def toThrift: thrift.CandidateTweet = {
thrift.CandidateTweet(
tweet = Some(hydratedTweet.tweet),
features = Some(features)
)
}
}

View File

@ -0,0 +1,37 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.util.Future
object CandidateTweetsResult {
val Empty: CandidateTweetsResult = CandidateTweetsResult(Nil, Nil)
val EmptyFuture: Future[CandidateTweetsResult] = Future.value(Empty)
val EmptyCandidateTweet: Seq[CandidateTweet] = Seq.empty[CandidateTweet]
def fromThrift(response: thrift.GetCandidateTweetsResponse): CandidateTweetsResult = {
val candidates = response.candidates
.map(_.map(CandidateTweet.fromThrift))
.getOrElse(EmptyCandidateTweet)
val sourceTweets = response.sourceTweets
.map(_.map(CandidateTweet.fromThrift))
.getOrElse(EmptyCandidateTweet)
if (sourceTweets.nonEmpty) {
require(candidates.nonEmpty, "sourceTweets cannot have a value if candidates list is empty.")
}
CandidateTweetsResult(candidates, sourceTweets)
}
}
case class CandidateTweetsResult(
candidates: Seq[CandidateTweet],
sourceTweets: Seq[CandidateTweet]) {
def toThrift: thrift.GetCandidateTweetsResponse = {
val thriftCandidates = candidates.map(_.toThrift)
val thriftSourceTweets = sourceTweets.map(_.toThrift)
thrift.GetCandidateTweetsResponse(
candidates = Some(thriftCandidates),
sourceTweets = Some(thriftSourceTweets)
)
}
}

View File

@ -0,0 +1,21 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.model.tweet.HydratedTweet
import com.twitter.tweetypie.{thriftscala => tweetypie}
/**
* Enables HydratedTweet entries to be included in a Timeline.
*/
class HydratedTweetEntry(tweet: tweetypie.Tweet) extends HydratedTweet(tweet) with TimelineEntry {
def this(hydratedTweet: HydratedTweet) = this(hydratedTweet.tweet)
override def toTimelineEntryThrift: thrift.TimelineEntry = {
thrift.TimelineEntry.TweetypieTweet(tweet)
}
override def throwIfInvalid(): Unit = {
// No validation performed.
}
}

View File

@ -0,0 +1,31 @@
package com.twitter.timelineranker.model
import com.twitter.common.text.language.LocaleUtil
import com.twitter.timelineranker.{thriftscala => thrift}
object Language {
def fromThrift(lang: thrift.Language): Language = {
require(lang.language.isDefined, "language can't be None")
require(lang.scope.isDefined, "scope can't be None")
Language(lang.language.get, LanguageScope.fromThrift(lang.scope.get))
}
}
/**
* Represents a language and the scope that it relates to.
*/
case class Language(language: String, scope: LanguageScope.Value) {
throwIfInvalid()
def toThrift: thrift.Language = {
val scopeOption = Some(LanguageScope.toThrift(scope))
thrift.Language(Some(language), scopeOption)
}
def throwIfInvalid(): Unit = {
val result = LocaleUtil.getLocaleOf(language)
require(result != LocaleUtil.UNKNOWN, s"Language ${language} is unsupported")
}
}

View File

@ -0,0 +1,46 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
/**
* Represents what this language is associated with.
* For example, "user" is one of the scopes and "event"
* could be another scope.
*/
object LanguageScope extends Enumeration {
// User scope means that the language is the user's language.
val User: Value = Value(thrift.LanguageScope.User.value)
// Event scope means that the language is the event's language.
val Event: Value = Value(thrift.LanguageScope.Event.value)
// list of all LanguageScope values
val All: ValueSet = LanguageScope.ValueSet(User, Event)
def apply(scope: thrift.LanguageScope): LanguageScope.Value = {
scope match {
case thrift.LanguageScope.User =>
User
case thrift.LanguageScope.Event =>
Event
case _ =>
throw new IllegalArgumentException(s"Unsupported language scope: $scope")
}
}
def fromThrift(scope: thrift.LanguageScope): LanguageScope.Value = {
apply(scope)
}
def toThrift(scope: LanguageScope.Value): thrift.LanguageScope = {
scope match {
case LanguageScope.User =>
thrift.LanguageScope.User
case LanguageScope.Event =>
thrift.LanguageScope.Event
case _ =>
throw new IllegalArgumentException(s"Unsupported language scope: $scope")
}
}
}

View File

@ -0,0 +1,184 @@
package com.twitter.timelineranker.model
import com.twitter.search.earlybird.thriftscala.ThriftSearchResult
import com.twitter.timelines.model.tweet.HydratedTweet
import com.twitter.timelines.model.TweetId
import com.twitter.timelines.model.UserId
import com.twitter.timelines.util.SnowflakeSortIndexHelper
import com.twitter.tweetypie.{thriftscala => tweetypie}
object PartiallyHydratedTweet {
private val InvalidValue = "Invalid value"
/**
* Creates an instance of PartiallyHydratedTweet based on the given search result.
*/
def fromSearchResult(result: ThriftSearchResult): PartiallyHydratedTweet = {
val tweetId = result.id
val metadata = result.metadata.getOrElse(
throw new IllegalArgumentException(
s"cannot initialize PartiallyHydratedTweet $tweetId without ThriftSearchResult metadata."
)
)
val extraMetadataOpt = metadata.extraMetadata
val userId = metadata.fromUserId
// The value of referencedTweetAuthorId and sharedStatusId is only considered valid if it is greater than 0.
val referencedTweetAuthorId =
if (metadata.referencedTweetAuthorId > 0) Some(metadata.referencedTweetAuthorId) else None
val sharedStatusId = if (metadata.sharedStatusId > 0) Some(metadata.sharedStatusId) else None
val isRetweet = metadata.isRetweet.getOrElse(false)
val retweetSourceTweetId = if (isRetweet) sharedStatusId else None
val retweetSourceUserId = if (isRetweet) referencedTweetAuthorId else None
// The fields sharedStatusId and referencedTweetAuthorId have overloaded meaning when
// this tweet is not a retweet (for retweet, there is only 1 meaning).
// When not a retweet,
// if referencedTweetAuthorId and sharedStatusId are both set, it is considered a reply
// if referencedTweetAuthorId is set and sharedStatusId is not set, it is a directed at tweet.
// References: SEARCH-8561 and SEARCH-13142
val inReplyToTweetId = if (!isRetweet) sharedStatusId else None
val inReplyToUserId = if (!isRetweet) referencedTweetAuthorId else None
val isReply = metadata.isReply.contains(true)
val quotedTweetId = extraMetadataOpt.flatMap(_.quotedTweetId)
val quotedUserId = extraMetadataOpt.flatMap(_.quotedUserId)
val isNullcast = metadata.isNullcast.contains(true)
val conversationId = extraMetadataOpt.flatMap(_.conversationId)
// Root author id for the user who posts an exclusive tweet
val exclusiveConversationAuthorId = extraMetadataOpt.flatMap(_.exclusiveConversationAuthorId)
// Card URI associated with an attached card to this tweet, if it contains one
val cardUri = extraMetadataOpt.flatMap(_.cardUri)
val tweet = makeTweetyPieTweet(
tweetId,
userId,
inReplyToTweetId,
inReplyToUserId,
retweetSourceTweetId,
retweetSourceUserId,
quotedTweetId,
quotedUserId,
isNullcast,
isReply,
conversationId,
exclusiveConversationAuthorId,
cardUri
)
new PartiallyHydratedTweet(tweet)
}
def makeTweetyPieTweet(
tweetId: TweetId,
userId: UserId,
inReplyToTweetId: Option[TweetId],
inReplyToUserId: Option[TweetId],
retweetSourceTweetId: Option[TweetId],
retweetSourceUserId: Option[UserId],
quotedTweetId: Option[TweetId],
quotedUserId: Option[UserId],
isNullcast: Boolean,
isReply: Boolean,
conversationId: Option[Long],
exclusiveConversationAuthorId: Option[Long] = None,
cardUri: Option[String] = None
): tweetypie.Tweet = {
val isDirectedAt = inReplyToUserId.isDefined
val isRetweet = retweetSourceTweetId.isDefined && retweetSourceUserId.isDefined
val reply = if (isReply) {
Some(
tweetypie.Reply(
inReplyToStatusId = inReplyToTweetId,
inReplyToUserId = inReplyToUserId.getOrElse(0L) // Required
)
)
} else None
val directedAt = if (isDirectedAt) {
Some(
tweetypie.DirectedAtUser(
userId = inReplyToUserId.get,
screenName = "" // not available from search
)
)
} else None
val share = if (isRetweet) {
Some(
tweetypie.Share(
sourceStatusId = retweetSourceTweetId.get,
sourceUserId = retweetSourceUserId.get,
parentStatusId =
retweetSourceTweetId.get // Not always correct (eg, retweet of a retweet).
)
)
} else None
val quotedTweet =
for {
tweetId <- quotedTweetId
userId <- quotedUserId
} yield tweetypie.QuotedTweet(tweetId = tweetId, userId = userId)
val coreData = tweetypie.TweetCoreData(
userId = userId,
text = InvalidValue,
createdVia = InvalidValue,
createdAtSecs = SnowflakeSortIndexHelper.idToTimestamp(tweetId).inSeconds,
directedAtUser = directedAt,
reply = reply,
share = share,
nullcast = isNullcast,
conversationId = conversationId
)
// Hydrate exclusiveTweetControl which determines whether the user is able to view an exclusive / SuperFollow tweet.
val exclusiveTweetControl = exclusiveConversationAuthorId.map { authorId =>
tweetypie.ExclusiveTweetControl(conversationAuthorId = authorId)
}
val cardReference = cardUri.map { cardUriFromEB =>
tweetypie.CardReference(cardUri = cardUriFromEB)
}
tweetypie.Tweet(
id = tweetId,
quotedTweet = quotedTweet,
coreData = Some(coreData),
exclusiveTweetControl = exclusiveTweetControl,
cardReference = cardReference
)
}
}
/**
* Represents an instance of HydratedTweet that is hydrated using search result
* (instead of being hydrated using TweetyPie service).
*
* Not all fields are available using search therefore such fields if accessed
* throw UnsupportedOperationException to ensure that they are not inadvertently
* accessed and relied upon.
*/
class PartiallyHydratedTweet(tweet: tweetypie.Tweet) extends HydratedTweet(tweet) {
override def parentTweetId: Option[TweetId] = throw notSupported("parentTweetId")
override def mentionedUserIds: Seq[UserId] = throw notSupported("mentionedUserIds")
override def takedownCountryCodes: Set[String] = throw notSupported("takedownCountryCodes")
override def hasMedia: Boolean = throw notSupported("hasMedia")
override def isNarrowcast: Boolean = throw notSupported("isNarrowcast")
override def hasTakedown: Boolean = throw notSupported("hasTakedown")
override def isNsfw: Boolean = throw notSupported("isNsfw")
override def isNsfwUser: Boolean = throw notSupported("isNsfwUser")
override def isNsfwAdmin: Boolean = throw notSupported("isNsfwAdmin")
private def notSupported(name: String): UnsupportedOperationException = {
new UnsupportedOperationException(s"Not supported: $name")
}
}

View File

@ -0,0 +1,23 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.model.TweetId
object PriorSeenEntries {
def fromThrift(entries: thrift.PriorSeenEntries): PriorSeenEntries = {
PriorSeenEntries(seenEntries = entries.seenEntries)
}
}
case class PriorSeenEntries(seenEntries: Seq[TweetId]) {
throwIfInvalid()
def toThrift: thrift.PriorSeenEntries = {
thrift.PriorSeenEntries(seenEntries = seenEntries)
}
def throwIfInvalid(): Unit = {
// No validation performed.
}
}

View File

@ -0,0 +1,14 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelineservice.model.TimelineId
case class RankedTimelineQuery(
override val id: TimelineId,
override val maxCount: Option[Int] = None,
override val range: Option[TimelineRange] = None,
override val options: Option[RankedTimelineQueryOptions] = None)
extends TimelineQuery(thrift.TimelineQueryType.Ranked, id, maxCount, range, options) {
throwIfInvalid()
}

View File

@ -0,0 +1,29 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
object RankedTimelineQueryOptions {
def fromThrift(options: thrift.RankedTimelineQueryOptions): RankedTimelineQueryOptions = {
RankedTimelineQueryOptions(
seenEntries = options.seenEntries.map(PriorSeenEntries.fromThrift)
)
}
}
case class RankedTimelineQueryOptions(seenEntries: Option[PriorSeenEntries])
extends TimelineQueryOptions {
throwIfInvalid()
def toThrift: thrift.RankedTimelineQueryOptions = {
thrift.RankedTimelineQueryOptions(seenEntries = seenEntries.map(_.toThrift))
}
def toTimelineQueryOptionsThrift: thrift.TimelineQueryOptions = {
thrift.TimelineQueryOptions.RankedTimelineQueryOptions(toThrift)
}
def throwIfInvalid(): Unit = {
seenEntries.foreach(_.throwIfInvalid)
}
}

View File

@ -0,0 +1,278 @@
package com.twitter.timelineranker.model
import com.twitter.servo.util.Gate
import com.twitter.timelines.model.candidate.CandidateTweetSourceId
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.common.model._
import com.twitter.timelines.earlybird.common.options.EarlybirdOptions
import com.twitter.timelines.earlybird.common.utils.SearchOperator
import com.twitter.timelines.configapi.{
DependencyProvider => ConfigApiDependencyProvider,
FutureDependencyProvider => ConfigApiFutureDependencyProvider,
_
}
import com.twitter.timelines.model.TweetId
import com.twitter.timelines.model.UserId
import com.twitter.timelineservice.DeviceContext
object RecapQuery {
val EngagedTweetsSupportedTweetKindOption: TweetKindOption.ValueSet = TweetKindOption(
includeReplies = false,
includeRetweets = false,
includeExtendedReplies = false,
includeOriginalTweetsAndQuotes = true
)
val DefaultSearchOperator: SearchOperator.Value = SearchOperator.Exclude
def fromThrift(query: thrift.RecapQuery): RecapQuery = {
RecapQuery(
userId = query.userId,
maxCount = query.maxCount,
range = query.range.map(TimelineRange.fromThrift),
options = query.options
.map(options => TweetKindOption.fromThrift(options.to[Set]))
.getOrElse(TweetKindOption.None),
searchOperator = query.searchOperator
.map(SearchOperator.fromThrift)
.getOrElse(DefaultSearchOperator),
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
authorIds = query.authorIds,
excludedTweetIds = query.excludedTweetIds,
searchClientSubId = query.searchClientSubId,
candidateTweetSourceId =
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
hydratesContentFeatures = query.hydratesContentFeatures
)
}
def fromThrift(query: thrift.RecapHydrationQuery): RecapQuery = {
require(query.tweetIds.nonEmpty, "tweetIds must be non-empty")
RecapQuery(
userId = query.userId,
tweetIds = Some(query.tweetIds),
searchOperator = DefaultSearchOperator,
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
candidateTweetSourceId =
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
hydratesContentFeatures = query.hydratesContentFeatures
)
}
def fromThrift(query: thrift.EngagedTweetsQuery): RecapQuery = {
val options = query.tweetKindOptions
.map(tweetKindOptions => TweetKindOption.fromThrift(tweetKindOptions.to[Set]))
.getOrElse(TweetKindOption.None)
if (!(options.isEmpty ||
(options == EngagedTweetsSupportedTweetKindOption))) {
throw new IllegalArgumentException(s"Unsupported TweetKindOption value: $options")
}
RecapQuery(
userId = query.userId,
maxCount = query.maxCount,
range = query.range.map(TimelineRange.fromThrift),
options = options,
searchOperator = DefaultSearchOperator,
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
authorIds = query.userIds,
excludedTweetIds = query.excludedTweetIds,
)
}
def fromThrift(query: thrift.EntityTweetsQuery): RecapQuery = {
require(
query.semanticCoreIds.isDefined,
"entities(semanticCoreIds) can't be None"
)
val options = query.tweetKindOptions
.map(tweetKindOptions => TweetKindOption.fromThrift(tweetKindOptions.to[Set]))
.getOrElse(TweetKindOption.None)
RecapQuery(
userId = query.userId,
maxCount = query.maxCount,
range = query.range.map(TimelineRange.fromThrift),
options = options,
searchOperator = DefaultSearchOperator,
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
excludedTweetIds = query.excludedTweetIds,
semanticCoreIds = query.semanticCoreIds.map(_.map(SemanticCoreAnnotation.fromThrift).toSet),
hashtags = query.hashtags.map(_.toSet),
languages = query.languages.map(_.map(Language.fromThrift).toSet),
candidateTweetSourceId =
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
includeNullcastTweets = query.includeNullcastTweets,
includeTweetsFromArchiveIndex = query.includeTweetsFromArchiveIndex,
authorIds = query.authorIds,
hydratesContentFeatures = query.hydratesContentFeatures
)
}
def fromThrift(query: thrift.UtegLikedByTweetsQuery): RecapQuery = {
val options = query.tweetKindOptions
.map(tweetKindOptions => TweetKindOption.fromThrift(tweetKindOptions.to[Set]))
.getOrElse(TweetKindOption.None)
RecapQuery(
userId = query.userId,
maxCount = query.maxCount,
range = query.range.map(TimelineRange.fromThrift),
options = options,
earlybirdOptions = query.earlybirdOptions.map(EarlybirdOptions.fromThrift),
deviceContext = query.deviceContext.map(DeviceContext.fromThrift),
excludedTweetIds = query.excludedTweetIds,
utegLikedByTweetsOptions = for {
utegCount <- query.utegCount
weightedFollowings <- query.weightedFollowings.map(_.toMap)
} yield {
UtegLikedByTweetsOptions(
utegCount = utegCount,
isInNetwork = query.isInNetwork,
weightedFollowings = weightedFollowings
)
},
candidateTweetSourceId =
query.candidateTweetSourceId.flatMap(CandidateTweetSourceId.fromThrift),
hydratesContentFeatures = query.hydratesContentFeatures
)
}
val paramGate: (Param[Boolean] => Gate[RecapQuery]) = HasParams.paramGate
type DependencyProvider[+T] = ConfigApiDependencyProvider[RecapQuery, T]
object DependencyProvider extends DependencyProviderFunctions[RecapQuery]
type FutureDependencyProvider[+T] = ConfigApiFutureDependencyProvider[RecapQuery, T]
object FutureDependencyProvider extends FutureDependencyProviderFunctions[RecapQuery]
}
/**
* Model object corresponding to RecapQuery thrift struct.
*/
case class RecapQuery(
userId: UserId,
maxCount: Option[Int] = None,
range: Option[TimelineRange] = None,
options: TweetKindOption.ValueSet = TweetKindOption.None,
searchOperator: SearchOperator.Value = RecapQuery.DefaultSearchOperator,
earlybirdOptions: Option[EarlybirdOptions] = None,
deviceContext: Option[DeviceContext] = None,
authorIds: Option[Seq[UserId]] = None,
tweetIds: Option[Seq[TweetId]] = None,
semanticCoreIds: Option[Set[SemanticCoreAnnotation]] = None,
hashtags: Option[Set[String]] = None,
languages: Option[Set[Language]] = None,
excludedTweetIds: Option[Seq[TweetId]] = None,
// options used only for yml tweets
utegLikedByTweetsOptions: Option[UtegLikedByTweetsOptions] = None,
searchClientSubId: Option[String] = None,
override val params: Params = Params.Empty,
candidateTweetSourceId: Option[CandidateTweetSourceId.Value] = None,
includeNullcastTweets: Option[Boolean] = None,
includeTweetsFromArchiveIndex: Option[Boolean] = None,
hydratesContentFeatures: Option[Boolean] = None)
extends HasParams {
override def toString: String = {
s"RecapQuery(userId: $userId, maxCount: $maxCount, range: $range, options: $options, searchOperator: $searchOperator, " +
s"earlybirdOptions: $earlybirdOptions, deviceContext: $deviceContext, authorIds: $authorIds, " +
s"tweetIds: $tweetIds, semanticCoreIds: $semanticCoreIds, hashtags: $hashtags, languages: $languages, excludedTweetIds: $excludedTweetIds, " +
s"utegLikedByTweetsOptions: $utegLikedByTweetsOptions, searchClientSubId: $searchClientSubId, " +
s"params: $params, candidateTweetSourceId: $candidateTweetSourceId, includeNullcastTweets: $includeNullcastTweets, " +
s"includeTweetsFromArchiveIndex: $includeTweetsFromArchiveIndex), hydratesContentFeatures: $hydratesContentFeatures"
}
def throwIfInvalid(): Unit = {
def noDuplicates[T <: Traversable[_]](elements: T) = {
elements.toSet.size == elements.size
}
maxCount.foreach { max => require(max > 0, "maxCount must be a positive integer") }
range.foreach(_.throwIfInvalid())
earlybirdOptions.foreach(_.throwIfInvalid())
tweetIds.foreach { ids => require(ids.nonEmpty, "tweetIds must be nonEmpty if present") }
semanticCoreIds.foreach(_.foreach(_.throwIfInvalid()))
languages.foreach(_.foreach(_.throwIfInvalid()))
languages.foreach { langs =>
require(langs.nonEmpty, "languages must be nonEmpty if present")
require(noDuplicates(langs.map(_.language)), "languages must be unique")
}
}
throwIfInvalid()
def toThriftRecapQuery: thrift.RecapQuery = {
val thriftOptions = Some(TweetKindOption.toThrift(options))
thrift.RecapQuery(
userId,
maxCount,
range.map(_.toTimelineRangeThrift),
deprecatedMinCount = None,
thriftOptions,
earlybirdOptions.map(_.toThrift),
deviceContext.map(_.toThrift),
authorIds,
excludedTweetIds,
Some(SearchOperator.toThrift(searchOperator)),
searchClientSubId,
candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift)
)
}
def toThriftRecapHydrationQuery: thrift.RecapHydrationQuery = {
require(tweetIds.isDefined && tweetIds.get.nonEmpty, "tweetIds must be present")
thrift.RecapHydrationQuery(
userId,
tweetIds.get,
earlybirdOptions.map(_.toThrift),
deviceContext.map(_.toThrift),
candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift)
)
}
def toThriftEntityTweetsQuery: thrift.EntityTweetsQuery = {
val thriftTweetKindOptions = Some(TweetKindOption.toThrift(options))
thrift.EntityTweetsQuery(
userId = userId,
maxCount = maxCount,
range = range.map(_.toTimelineRangeThrift),
tweetKindOptions = thriftTweetKindOptions,
earlybirdOptions = earlybirdOptions.map(_.toThrift),
deviceContext = deviceContext.map(_.toThrift),
excludedTweetIds = excludedTweetIds,
semanticCoreIds = semanticCoreIds.map(_.map(_.toThrift)),
hashtags = hashtags,
languages = languages.map(_.map(_.toThrift)),
candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift),
includeNullcastTweets = includeNullcastTweets,
includeTweetsFromArchiveIndex = includeTweetsFromArchiveIndex,
authorIds = authorIds
)
}
def toThriftUtegLikedByTweetsQuery: thrift.UtegLikedByTweetsQuery = {
val thriftTweetKindOptions = Some(TweetKindOption.toThrift(options))
thrift.UtegLikedByTweetsQuery(
userId = userId,
maxCount = maxCount,
utegCount = utegLikedByTweetsOptions.map(_.utegCount),
range = range.map(_.toTimelineRangeThrift),
tweetKindOptions = thriftTweetKindOptions,
earlybirdOptions = earlybirdOptions.map(_.toThrift),
deviceContext = deviceContext.map(_.toThrift),
excludedTweetIds = excludedTweetIds,
isInNetwork = utegLikedByTweetsOptions.map(_.isInNetwork).get,
weightedFollowings = utegLikedByTweetsOptions.map(_.weightedFollowings),
candidateTweetSourceId = candidateTweetSourceId.flatMap(CandidateTweetSourceId.toThrift)
)
}
}

View File

@ -0,0 +1,23 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelineservice.model.TimelineId
object ReverseChronTimelineQuery {
def fromTimelineQuery(query: TimelineQuery): ReverseChronTimelineQuery = {
query match {
case q: ReverseChronTimelineQuery => q
case _ => throw new IllegalArgumentException(s"Unsupported query type: $query")
}
}
}
case class ReverseChronTimelineQuery(
override val id: TimelineId,
override val maxCount: Option[Int] = None,
override val range: Option[TimelineRange] = None,
override val options: Option[ReverseChronTimelineQueryOptions] = None)
extends TimelineQuery(thrift.TimelineQueryType.ReverseChron, id, maxCount, range, options) {
throwIfInvalid()
}

View File

@ -0,0 +1,31 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
object ReverseChronTimelineQueryOptions {
val Default: ReverseChronTimelineQueryOptions = ReverseChronTimelineQueryOptions()
def fromThrift(
options: thrift.ReverseChronTimelineQueryOptions
): ReverseChronTimelineQueryOptions = {
ReverseChronTimelineQueryOptions(
getTweetsFromArchiveIndex = options.getTweetsFromArchiveIndex
)
}
}
case class ReverseChronTimelineQueryOptions(getTweetsFromArchiveIndex: Boolean = true)
extends TimelineQueryOptions {
throwIfInvalid()
def toThrift: thrift.ReverseChronTimelineQueryOptions = {
thrift.ReverseChronTimelineQueryOptions(getTweetsFromArchiveIndex = getTweetsFromArchiveIndex)
}
def toTimelineQueryOptionsThrift: thrift.TimelineQueryOptions = {
thrift.TimelineQueryOptions.ReverseChronTimelineQueryOptions(toThrift)
}
def throwIfInvalid(): Unit = {}
}

View File

@ -0,0 +1,39 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.util.Time
object TimeRange {
val default: TimeRange = TimeRange(None, None)
def fromThrift(range: thrift.TimeRange): TimeRange = {
TimeRange(
from = range.fromMs.map(Time.fromMilliseconds),
to = range.toMs.map(Time.fromMilliseconds)
)
}
}
case class TimeRange(from: Option[Time], to: Option[Time]) extends TimelineRange {
throwIfInvalid()
def throwIfInvalid(): Unit = {
(from, to) match {
case (Some(fromTime), Some(toTime)) =>
require(fromTime <= toTime, "from-time must be less than or equal to-time.")
case _ => // valid, do nothing.
}
}
def toThrift: thrift.TimeRange = {
thrift.TimeRange(
fromMs = from.map(_.inMilliseconds),
toMs = to.map(_.inMilliseconds)
)
}
def toTimelineRangeThrift: thrift.TimelineRange = {
thrift.TimelineRange.TimeRange(toThrift)
}
}

View File

@ -0,0 +1,46 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.model.UserId
import com.twitter.timelineservice.model.TimelineId
import com.twitter.timelineservice.model.core.TimelineKind
object Timeline {
def empty(id: TimelineId): Timeline = {
Timeline(id, Nil)
}
def fromThrift(timeline: thrift.Timeline): Timeline = {
Timeline(
id = TimelineId.fromThrift(timeline.id),
entries = timeline.entries.map(TimelineEntryEnvelope.fromThrift)
)
}
def throwIfIdInvalid(id: TimelineId): Unit = {
// Note: if we support timelines other than TimelineKind.home, we need to update
// the implementation of userId method here and in TimelineQuery class.
require(id.kind == TimelineKind.home, s"Expected TimelineKind.home, found: ${id.kind}")
}
}
case class Timeline(id: TimelineId, entries: Seq[TimelineEntryEnvelope]) {
throwIfInvalid()
def userId: UserId = {
id.id
}
def throwIfInvalid(): Unit = {
Timeline.throwIfIdInvalid(id)
entries.foreach(_.throwIfInvalid())
}
def toThrift: thrift.Timeline = {
thrift.Timeline(
id = id.toThrift,
entries = entries.map(_.toThrift)
)
}
}

View File

@ -0,0 +1,18 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
object TimelineEntry {
def fromThrift(entry: thrift.TimelineEntry): TimelineEntry = {
entry match {
case thrift.TimelineEntry.Tweet(e) => Tweet.fromThrift(e)
case thrift.TimelineEntry.TweetypieTweet(e) => new HydratedTweetEntry(e)
case _ => throw new IllegalArgumentException(s"Unsupported type: $entry")
}
}
}
trait TimelineEntry {
def toTimelineEntryThrift: thrift.TimelineEntry
def throwIfInvalid(): Unit
}

View File

@ -0,0 +1,24 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
object TimelineEntryEnvelope {
def fromThrift(entryEnvelope: thrift.TimelineEntryEnvelope): TimelineEntryEnvelope = {
TimelineEntryEnvelope(
entry = TimelineEntry.fromThrift(entryEnvelope.entry)
)
}
}
case class TimelineEntryEnvelope(entry: TimelineEntry) {
throwIfInvalid()
def toThrift: thrift.TimelineEntryEnvelope = {
thrift.TimelineEntryEnvelope(entry.toTimelineEntryThrift)
}
def throwIfInvalid(): Unit = {
entry.throwIfInvalid()
}
}

View File

@ -0,0 +1,82 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.model.UserId
import com.twitter.timelineservice.model.TimelineId
object TimelineQuery {
def fromThrift(query: thrift.TimelineQuery): TimelineQuery = {
val queryType = query.queryType
val id = TimelineId.fromThrift(query.timelineId)
val maxCount = query.maxCount
val range = query.range.map(TimelineRange.fromThrift)
val options = query.options.map(TimelineQueryOptions.fromThrift)
queryType match {
case thrift.TimelineQueryType.Ranked =>
val rankedOptions = getRankedOptions(options)
RankedTimelineQuery(id, maxCount, range, rankedOptions)
case thrift.TimelineQueryType.ReverseChron =>
val reverseChronOptions = getReverseChronOptions(options)
ReverseChronTimelineQuery(id, maxCount, range, reverseChronOptions)
case _ =>
throw new IllegalArgumentException(s"Unsupported query type: $queryType")
}
}
def getRankedOptions(
options: Option[TimelineQueryOptions]
): Option[RankedTimelineQueryOptions] = {
options.map {
case o: RankedTimelineQueryOptions => o
case _ =>
throw new IllegalArgumentException(
"Only RankedTimelineQueryOptions are supported when queryType is TimelineQueryType.Ranked"
)
}
}
def getReverseChronOptions(
options: Option[TimelineQueryOptions]
): Option[ReverseChronTimelineQueryOptions] = {
options.map {
case o: ReverseChronTimelineQueryOptions => o
case _ =>
throw new IllegalArgumentException(
"Only ReverseChronTimelineQueryOptions are supported when queryType is TimelineQueryType.ReverseChron"
)
}
}
}
abstract class TimelineQuery(
private val queryType: thrift.TimelineQueryType,
val id: TimelineId,
val maxCount: Option[Int],
val range: Option[TimelineRange],
val options: Option[TimelineQueryOptions]) {
throwIfInvalid()
def userId: UserId = {
id.id
}
def throwIfInvalid(): Unit = {
Timeline.throwIfIdInvalid(id)
range.foreach(_.throwIfInvalid())
options.foreach(_.throwIfInvalid())
}
def toThrift: thrift.TimelineQuery = {
thrift.TimelineQuery(
queryType = queryType,
timelineId = id.toThrift,
maxCount = maxCount,
range = range.map(_.toTimelineRangeThrift),
options = options.map(_.toTimelineQueryOptionsThrift)
)
}
}

View File

@ -0,0 +1,20 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
object TimelineQueryOptions {
def fromThrift(options: thrift.TimelineQueryOptions): TimelineQueryOptions = {
options match {
case thrift.TimelineQueryOptions.RankedTimelineQueryOptions(r) =>
RankedTimelineQueryOptions.fromThrift(r)
case thrift.TimelineQueryOptions.ReverseChronTimelineQueryOptions(r) =>
ReverseChronTimelineQueryOptions.fromThrift(r)
case _ => throw new IllegalArgumentException(s"Unsupported type: $options")
}
}
}
trait TimelineQueryOptions {
def toTimelineQueryOptionsThrift: thrift.TimelineQueryOptions
def throwIfInvalid(): Unit
}

View File

@ -0,0 +1,18 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
object TimelineRange {
def fromThrift(range: thrift.TimelineRange): TimelineRange = {
range match {
case thrift.TimelineRange.TimeRange(r) => TimeRange.fromThrift(r)
case thrift.TimelineRange.TweetIdRange(r) => TweetIdRange.fromThrift(r)
case _ => throw new IllegalArgumentException(s"Unsupported type: $range")
}
}
}
trait TimelineRange {
def toTimelineRangeThrift: thrift.TimelineRange
def throwIfInvalid(): Unit
}

View File

@ -0,0 +1,62 @@
package com.twitter.timelineranker.model
import com.twitter.search.earlybird.thriftscala._
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.model.TweetId
import com.twitter.timelines.model.UserId
object Tweet {
def fromThrift(tweet: thrift.Tweet): Tweet = {
Tweet(id = tweet.id)
}
}
case class Tweet(
id: TweetId,
userId: Option[UserId] = None,
sourceTweetId: Option[TweetId] = None,
sourceUserId: Option[UserId] = None)
extends TimelineEntry {
throwIfInvalid()
def throwIfInvalid(): Unit = {}
def toThrift: thrift.Tweet = {
thrift.Tweet(
id = id,
userId = userId,
sourceTweetId = sourceTweetId,
sourceUserId = sourceUserId)
}
def toTimelineEntryThrift: thrift.TimelineEntry = {
thrift.TimelineEntry.Tweet(toThrift)
}
def toThriftSearchResult: ThriftSearchResult = {
val metadata = ThriftSearchResultMetadata(
resultType = ThriftSearchResultType.Recency,
fromUserId = userId match {
case Some(id) => id
case None => 0L
},
isRetweet =
if (sourceUserId.isDefined || sourceUserId.isDefined) Some(true)
else
None,
sharedStatusId = sourceTweetId match {
case Some(id) => id
case None => 0L
},
referencedTweetAuthorId = sourceUserId match {
case Some(id) => id
case None => 0L
}
)
ThriftSearchResult(
id = id,
metadata = Some(metadata)
)
}
}

View File

@ -0,0 +1,53 @@
package com.twitter.timelineranker.model
import com.twitter.timelineranker.{thriftscala => thrift}
import com.twitter.timelines.model.TweetId
object TweetIdRange {
val default: TweetIdRange = TweetIdRange(None, None)
val empty: TweetIdRange = TweetIdRange(Some(0L), Some(0L))
def fromThrift(range: thrift.TweetIdRange): TweetIdRange = {
TweetIdRange(fromId = range.fromId, toId = range.toId)
}
def fromTimelineRange(range: TimelineRange): TweetIdRange = {
range match {
case r: TweetIdRange => r
case _ =>
throw new IllegalArgumentException(s"Only Tweet ID range is supported. Found: $range")
}
}
}
/**
* A range of Tweet IDs with exclusive bounds.
*/
case class TweetIdRange(fromId: Option[TweetId] = None, toId: Option[TweetId] = None)
extends TimelineRange {
throwIfInvalid()
def throwIfInvalid(): Unit = {
(fromId, toId) match {
case (Some(fromTweetId), Some(toTweetId)) =>
require(fromTweetId <= toTweetId, "fromId must be less than or equal to toId.")
case _ => // valid, do nothing.
}
}
def toThrift: thrift.TweetIdRange = {
thrift.TweetIdRange(fromId = fromId, toId = toId)
}
def toTimelineRangeThrift: thrift.TimelineRange = {
thrift.TimelineRange.TweetIdRange(toThrift)
}
def isEmpty: Boolean = {
(fromId, toId) match {
case (Some(fromId), Some(toId)) if fromId == toId => true
case _ => false
}
}
}

View File

@ -0,0 +1,8 @@
package com.twitter.timelineranker.model
import com.twitter.timelines.model.UserId
case class UtegLikedByTweetsOptions(
utegCount: Int,
isInNetwork: Boolean,
weightedFollowings: Map[UserId, Double])