Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
044da6d
ImageLoader maybeEmbedder is dependant on the optional queue URL and …
tonytw1 Jul 8, 2026
f8df30a
MediaAPI config handling of embedder queueUrl is consistent with imag…
tonytw1 Jul 10, 2026
2f5b3ac
Media API optional embedder consistant with image loader.
tonytw1 Jul 24, 2026
ac50cfa
[generalise-embedding-impl] Extract common embedding implementation i…
tonytw1 Jul 24, 2026
720e483
[generalise-embedding-impl] createImageEmbeddings requires a mimeType.
tonytw1 Jul 24, 2026
f09232a
[generalise-embedding-impl] EmbeddingImplementation returns a full Em…
tonytw1 Jul 24, 2026
0002d2d
Port Bedrock create image embedding back from lambda. Bedrock impleme…
tonytw1 Jul 3, 2026
81a580e
createImageEmbedding is available on Embedder interface.
tonytw1 Jul 24, 2026
8adbc92
Unit
tonytw1 Jul 24, 2026
5c3ced2
[generalise-embedding-impl] Embedder takes a general EmbeddingImpleme…
tonytw1 Jun 28, 2026
ee67648
[embedding-source] embeddingSourceImageFormat exposed on Embedder so …
tonytw1 Jul 24, 2026
cc0a70c
[embedding-source] ImageOperations.createEmbeddingSource to normalise…
tonytw1 May 9, 2026
08fcf54
[embedding-source] In memory copy. Strips metadata and may not render…
tonytw1 Jul 16, 2026
74f1208
[embedding-source] Explicitly strip metadata so that we be sure that …
tonytw1 Jun 28, 2026
83d5845
[embedding-source] Support optional letter boxing the embedding sourc…
tonytw1 May 20, 2026
2866082
[embedding-source] Increasing the output size for the createEmbedding…
tonytw1 Jul 18, 2026
4e2557c
[send-embedder-sqs-message] Introduce the embedding source S3 bucket …
tonytw1 Jun 28, 2026
997e74d
[embedding-source] createEmbeddingSource is file based for consistanc…
tonytw1 Jul 15, 2026
2dbf51e
[thrall-embedding] Placeholder for SQS embed required message consume…
tonytw1 Jun 20, 2026
8d9fdbb
[thrall-embedding] Parse EmbedderMessage
tonytw1 Jul 4, 2026
14777d4
[thrall-embedding] EmbeddingSqsConsumer gets store and embedder
tonytw1 Jul 4, 2026
26aa22c
[thrall-embedding] Picking up the embed source file from S3 in thrall.
tonytw1 Jun 25, 2026
7decc96
[thrall-embedding] Parse embedding source image mimeType.
tonytw1 Jul 12, 2026
bb8625f
[thrall-embedding] Make create embedding call and produce an Embedding.
tonytw1 Jul 26, 2026
b456b28
[thrall-embedding] Send update embed message.
tonytw1 Jul 4, 2026
2a56428
[thrall-embedding] embedding source mimetype is extracted from S3 hea…
tonytw1 Jul 24, 2026
33b7fcc
Drop fileType from EmbedderMessage; it is already past on the S3 obje…
tonytw1 Jul 12, 2026
1e70ff6
[mapping] Provide a geminiEmbedding2 field on Embedding.
tonytw1 May 11, 2026
71bab06
[gemini-embedding] Initial Gemini Embedding 2 client.
tonytw1 May 26, 2026
e9bf437
[gemini-embedding] Implement text query to vector
tonytw1 May 10, 2026
a997cec
[gemini-embedding] Use task type on query embedding.
tonytw1 May 13, 2026
42f4ddd
[gemini-embedding] Revert; worked better without?
tonytw1 May 16, 2026
bf5a322
[gemini-embedding] Embedding for uploaded image contains title and de…
tonytw1 Jun 28, 2026
94801c3
[gemini-embedding] Refactor; setup for config.
tonytw1 Jun 20, 2026
0a8247f
[gemini-embedding] provides embeddingSourceImageFormat.
tonytw1 Jun 28, 2026
f79f3f9
[gemini-embedding] createImageEmbeddings requires a mimeType.
tonytw1 Jul 24, 2026
ee4164d
[gemini-embedding] extends embedding implementation interface.
tonytw1 Jun 28, 2026
54770da
[gemini-embedding] Provide a geminiEmbedding2 mapping with Google's r…
tonytw1 Jul 5, 2026
6a90884
[gemini-embedding] Define googleCloudEmbedding component to replace B…
tonytw1 Jun 20, 2026
307dd2d
[gemini-embedding] Billing is not size dependant so we can use a larg…
tonytw1 Jul 18, 2026
7a293c0
[gemini-embedding] Does not need letter boxing.
tonytw1 Jul 18, 2026
4dc4f1e
[gemini-embedding] Use JPEG
tonytw1 Jul 18, 2026
262d2df
[gemini-embedding] set taskTypes on embedding request.
tonytw1 Jul 19, 2026
321f954
Thrall switches to GCP embedding.
tonytw1 Jul 12, 2026
759e384
[query] Use geminiEmbedding2 Elastic mapping field.
tonytw1 May 11, 2026
2a0eda7
Test
tonytw1 Jul 23, 2026
7df9df5
[query] knnSearch mapping field to use is a constant.
tonytw1 Jun 13, 2026
01f0041
[query] Switch knn search field to embeddin.geminiEmbedding2.image.
tonytw1 Jun 13, 2026
6b04d0e
[query] Restrict KNN with similarity filter to prevent long crawls.
tonytw1 Jul 5, 2026
45f1962
[query] Setting up to use similar too as boolean clause of normal sea…
tonytw1 Jul 5, 2026
1fa5653
[query] Pass maybeSimilarToVector down to normal search.
tonytw1 May 16, 2026
af8c3ac
[query] knn is constrained to the withFilter query. knn should look l…
tonytw1 May 16, 2026
f4b5474
[query] searchRequest is a normal query or a knn with the query as it…
tonytw1 May 17, 2026
a689c19
[query] Ignore similar when building Elastic query from structuredQue…
tonytw1 May 17, 2026
b60d17b
[query] Not needed.
tonytw1 Jul 23, 2026
0b181f8
Happy with numCandiates to be 2 * k given we have a constraint on sim…
tonytw1 Jun 14, 2026
61360ad
Bigger cast?
tonytw1 May 17, 2026
3593ea2
[ui] More List This link is conditional on this image having an embed…
tonytw1 May 18, 2026
5b9830b
[ui] Clicking More Like This does not need to set the Use AI Search t…
tonytw1 May 18, 2026
a775307
[thrall-embedding] Pass uploaded image metadata to the embedder via t…
tonytw1 Jul 12, 2026
f26150f
[thrall-embedding] Make image metadata from the SQS message availble …
tonytw1 Jul 12, 2026
ebb0743
Test fixes.
tonytw1 Jul 24, 2026
239a5bf
Relax / Tighten.
tonytw1 Jul 14, 2026
d92abdf
Bound the semanticRequest knn by similarity to prevent long explores …
tonytw1 Jul 24, 2026
2cf05f0
Provide media api config hooks for tuning aiSearchMinimumSimilarity a…
tonytw1 Jul 26, 2026
fc8c860
Store the embedding results and project for future reindexing.
tonytw1 Jul 26, 2026
94a5ea9
[te] fetchEmbeddingResult checkes for file existance.
tonytw1 Jul 26, 2026
87f03ea
[te] Rename and source key on fetch.
tonytw1 Jul 26, 2026
dd94582
[te] createEmbeddingsSource moves to ImageUploadOpsDependencies so th…
tonytw1 Jul 26, 2026
d114c86
[te] Clean up; more natural ordering.
tonytw1 Jul 26, 2026
ad680e0
[te] Clean up; similar name.
tonytw1 Jul 26, 2026
24f3cdb
[te] Embeddings move to their own bucket for simpler life cycling.
tonytw1 Jul 26, 2026
817806b
[te] Standard path.
tonytw1 Jul 26, 2026
4d571bb
[te] Embedding are deleted on from the embedding bucket on hard reaping.
tonytw1 Aug 2, 2026
a8590a0
[te] Clean up; delete unused S3Vectors.
tonytw1 Aug 2, 2026
7a608d9
testcontainers-localstack 2.0.5 and testcontainers-elasticsearch 2.0.5
tonytw1 Jul 30, 2026
91f16cf
Rebase.
tonytw1 Aug 13, 2026
ecd8534
Marking TODO.
tonytw1 Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ val commonSettings = Seq(
"org.scalatestplus" %% "mockito-3-4" % "3.1.4.0" % Test,
"org.mockito" % "mockito-core" % "2.18.0" % Test,
"org.scalamock" %% "scalamock" % "5.1.0" % Test,
"org.testcontainers" % "localstack" % "1.21.4" % Test
"org.testcontainers" % "testcontainers-localstack" % "2.0.5" % Test
),
dependencyOverrides ++= jacksonOverrides,

Expand Down Expand Up @@ -125,10 +125,10 @@ lazy val commonLib = project("common-lib").settings(
"software.amazon.awssdk" % "cloudwatch" % awsSdkV2Version,
// declare explicit dependency on desired version of aws sdk v2 bedrock runtime
"software.amazon.awssdk" % "bedrockruntime" % awsSdkV2Version,
"software.amazon.awssdk" % "s3vectors" % awsSdkV2Version,
"com.adobe.xmp" % "xmpcore" % "6.1.11",
ws,
"org.testcontainers" % "testcontainers-elasticsearch" % "2.0.2" % Test,
"com.google.genai" % "google-genai" % "1.53.0" exclude("com.fasterxml.jackson.module", "jackson-module-kotlin"),
"org.testcontainers" % "testcontainers-elasticsearch" % "2.0.5" % Test,
),
dependencyOverrides += "ch.qos.logback" % "logback-classic" % "1.2.13" % Test
)
Expand Down Expand Up @@ -186,10 +186,14 @@ lazy val thrall = playProject("thrall", 9002)
"com.gu" %% "kcl-pekko-stream" % "0.1.2",
"org.testcontainers" % "testcontainers-elasticsearch" % "2.0.2" % Test,
"com.google.protobuf" % "protobuf-java" % "3.19.6",
"software.amazon.awssdk" % "sqs" % awsSdkV2Version
"software.amazon.awssdk" % "sqs" % awsSdkV2Version,
"org.apache.pekko" %% "pekko-connectors-sqs" % "1.0.2"
),
dependencyOverrides ++= Seq(
"org.apache.pekko" %% "pekko-stream" % "1.0.3"
"org.apache.pekko" %% "pekko-stream" % "1.0.3",
"org.apache.pekko" %% "pekko-http" % "1.0.1",
"org.apache.pekko" %% "pekko-http-core" % "1.0.1",
"org.apache.pekko" %% "pekko-parsing" % "1.0.1"
)
)

Expand Down
7 changes: 3 additions & 4 deletions collections/test/store/CollectionsStoreTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.time.{Millis, Seconds, Span}
import org.testcontainers.containers.localstack.LocalStackContainer
import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB
import org.testcontainers.localstack.LocalStackContainer
import org.testcontainers.utility.DockerImageName
import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider}
import software.amazon.awssdk.regions.Region
Expand All @@ -25,11 +24,11 @@ class CollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutures wi

implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(2, Seconds), interval = Span(100, Millis))

private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB)
private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices("dynamodb")
dynamoContainer.start()

private val dynamoClient = DynamoDbAsyncClient.builder().
endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)).
endpointOverride(dynamoContainer.getEndpoint).
region(Region.of(dynamoContainer.getRegion)).
credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build()

Expand Down
7 changes: 3 additions & 4 deletions collections/test/store/ImageCollectionsStoreTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.time.{Millis, Seconds, Span}
import org.testcontainers.containers.localstack.LocalStackContainer
import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB
import org.testcontainers.localstack.LocalStackContainer
import org.testcontainers.utility.DockerImageName
import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider}
import software.amazon.awssdk.regions.Region
Expand All @@ -22,11 +21,11 @@ class ImageCollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutur

implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(5, Seconds), interval = Span(500, Millis))

private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB)
private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices("dynamodb")
dynamoContainer.start()

private val dynamoClient = DynamoDbAsyncClient.builder().
endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)).
endpointOverride(dynamoContainer.getEndpoint).
region(Region.of(dynamoContainer.getRegion)).
credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.gu.mediaservice.lib


import _root_.play.api.libs.json._
import com.gu.mediaservice.lib.aws.{S3Bucket, S3Object}
import com.gu.mediaservice.lib.config.CommonConfig
import com.gu.mediaservice.lib.logging.LogMarker
import com.gu.mediaservice.model.{Instance, MimeType}
import com.gu.mediaservice.model.{Embedding, Instance, MimeType}
import com.typesafe.scalalogging.StrictLogging
import org.joda.time.DateTime
import software.amazon.awssdk.core.ResponseInputStream
import software.amazon.awssdk.services.s3.model.GetObjectResponse

import java.io.File
import scala.concurrent.Future
Expand All @@ -16,19 +18,22 @@ object ImageIngestOperations {

def optimisedPngKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + "optimised/" + snippetForId(id: String)

def embeddingKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + snippetForId(id)

private def snippetForId(id: String) = id.take(6).mkString("/") + "/" + id
}

class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, config: CommonConfig, isVersionedS3: Boolean = false)
class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, embeddingSourceBucket: S3Bucket, embeddingsBucket: S3Bucket, config: CommonConfig, isVersionedS3: Boolean = false)
extends S3ImageStorage(config) with StrictLogging {

import ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId}
import ImageIngestOperations.{embeddingKeyFromId, fileKeyFromId, optimisedPngKeyFromId}

def store(storableImage: StorableImage)
(implicit logMarker: LogMarker): Future[S3Object] = storableImage match {
case s:StorableOriginalImage => storeOriginalImage(s)
case s:StorableThumbImage => storeThumbnailImage(s)
case s:StorableOptimisedImage => storeOptimisedImage(s)
case s:StorableEmbeddingSourceImage => storeEmbeddingSourceImage(s)
}

private def storeOriginalImage(storableImage: StorableOriginalImage)
Expand All @@ -55,6 +60,23 @@ class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, co
overwrite = true)
}

private def storeEmbeddingSourceImage(storableImage: StorableEmbeddingSourceImage)
(implicit logMarker: LogMarker): Future[S3Object] = {
val instanceSpecificKey = fileKeyFromId(storableImage.id)(storableImage.instance)
logger.info(s"Storing embedding source to instance specific key: ${embeddingSourceBucket.bucket} / $instanceSpecificKey")
storeImage(embeddingSourceBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType),
overwrite = true)
}

def getEmbeddingStoreImage(key: String): ResponseInputStream[GetObjectResponse] = {
getObjectV2(embeddingSourceBucket, key)
}

def storeEmbedding(key: String, embedding: Embedding): Unit = {
logger.info(s"Storing embedding to key: ${embeddingsBucket.bucket} / $key")
putString(embeddingsBucket, key, Json.stringify(Json.toJson(embedding)))
}

private def bulkDeleteV2(bucket: S3Bucket, keys: List[String]): Future[Map[String, Boolean]] = keys match {
case Nil => Future.successful(Map.empty)
case _ => Future {
Expand All @@ -68,6 +90,7 @@ class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, co
def deleteThumbnails(ids: Set[String])(implicit instance: Instance) = bulkDeleteV2(thumbnailBucket, ids.map(id => fileKeyFromId(id)).toList)
def deletePNG(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = deleteImage(imageBucket, optimisedPngKeyFromId(id))
def deletePNGs(ids: Set[String])(implicit instance: Instance) = bulkDeleteV2(imageBucket, ids.map(id => optimisedPngKeyFromId(id)).toList)
def deleteEmbeddings(ids: Set[String])(implicit instance: Instance) = bulkDeleteV2(embeddingsBucket, ids.map(id => embeddingKeyFromId(id)).toList)

def doesOriginalExistV2(id: String)(implicit instance: Instance): Boolean = {
this.doesObjectExistV2(imageBucket, fileKeyFromId(id))
Expand Down Expand Up @@ -122,7 +145,16 @@ case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, me
meta = meta
)
}

case class StorableEmbeddingSourceImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage {
override def toProjectedS3Object(embeddingSourcesBucket: S3Bucket): S3Object = S3Object(
embeddingSourcesBucket,
ImageIngestOperations.fileKeyFromId(id)(instance),
file,
Some(mimeType),
lastModified = None,
meta = meta
)
}

/**
* @param id
Expand All @@ -136,5 +168,6 @@ case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, me
case class BrowserViewableImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, isTransformedFromSource: Boolean = false, instance: Instance) extends ImageWrapper {
def asStorableOptimisedImage = StorableOptimisedImage(id, file, mimeType, meta, instance)
def asStorableThumbImage = StorableThumbImage(id, file, mimeType, meta, instance)
def asStorableEmbeddingSourceImage = StorableEmbeddingSourceImage(id, file, mimeType, meta, instance)
}

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class KeyStore(bucket: S3Bucket, s3: S3)(implicit ec: ExecutionContext)

private def fetchAll: Map[String, ApiAccessor] = {
val objects = Await.result(s3.listV2(bucket, ""), 10.seconds)
val keys = objects.map(_.uri.getPath.stripPrefix("/"))
val keys = objects.map(_.uri.getPath.stripPrefix("/")) // TODO use unction!
keys.flatMap(k => getS3Object(k).map(k -> ApiAccessor(_))).toMap
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
package com.gu.mediaservice.lib.aws

import software.amazon.awssdk.services.bedrockruntime.model._
import software.amazon.awssdk.services.bedrockruntime._
import com.gu.mediaservice.lib.config.CommonConfig
import play.api.libs.json.Json
import software.amazon.awssdk.core.SdkBytes

import java.net.URI
import com.gu.mediaservice.lib.embeddings.{EmbeddingImplementation, EmbeddingSourceImageFormat}
import com.gu.mediaservice.lib.logging.LogMarker
import com.gu.mediaservice.model.{CohereV4Embedding, Embedding, ImageMetadata, Jpeg, MimeType}
import org.apache.commons.codec.binary.Base64
import play.api.libs.json.OFormat.oFormatFromReadsAndOWrites
import play.api.libs.json._
import software.amazon.awssdk.core.SdkBytes
import software.amazon.awssdk.services.bedrockruntime._
import software.amazon.awssdk.services.bedrockruntime.model._

import java.net.URI
import scala.concurrent.{ExecutionContext, Future}

object Bedrock {
Expand All @@ -22,10 +23,19 @@ object Bedrock {
)

private implicit val bedrockTextRequestFormat: OFormat[BedrockTextRequest] = Json.format[BedrockTextRequest]

case class BedrockImageRequest(
input_type: String,
embedding_types: List[String],
images: List[String],
output_dimension: Int
)

private implicit val bedrockImageRequestFormat: OFormat[BedrockImageRequest] = Json.format[BedrockImageRequest]
}

class Bedrock(config: CommonConfig)
extends AwsClientV2BuilderUtils {
extends EmbeddingImplementation with AwsClientV2BuilderUtils {

// TODO: figure out what the more usual pattern for turning off localstack behaviour is
override def awsLocalEndpointUri: Option[URI] = None
Expand All @@ -37,7 +47,7 @@ class Bedrock(config: CommonConfig)
.build()
}

private def createRequestBody(inputData: String): InvokeModelRequest = {
private def createSearchQueryRequestBody(inputData: String): InvokeModelRequest = {
val body = Bedrock.BedrockTextRequest(
input_type = "search_query",
embedding_types = List("float"),
Expand All @@ -58,6 +68,30 @@ class Bedrock(config: CommonConfig)
request
}

private def createImageSearchDocumentRequestBody(base64Image: String, imageMimeType: MimeType): InvokeModelRequest = {
val body = Bedrock.BedrockImageRequest(
input_type = "search_document",
embedding_types = List("float"),
images = List(
s"`data:${imageMimeType.name};base64,$base64Image`"
),
output_dimension = 1536
)

val jsonBody = Json.toJson(body).toString()

val request: InvokeModelRequest = {
InvokeModelRequest
.builder()
.accept("*/*")
.body(SdkBytes.fromUtf8String(jsonBody))
.contentType("application/json")
.modelId("global.cohere.embed-v4:0")
.build()
}
request
}

private def sendBedrockEmbeddingRequest(requestBody: InvokeModelRequest)(
implicit logMarker: LogMarker
): InvokeModelResponse = {
Expand All @@ -77,7 +111,7 @@ class Bedrock(config: CommonConfig)
}

def createTextEmbedding(inputData: String)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[List[Double]] = {
val requestBody = createRequestBody(inputData)
val requestBody = createSearchQueryRequestBody(inputData)
val bedrockFuture = Future { sendBedrockEmbeddingRequest(requestBody) }
bedrockFuture.map { response =>
val responseBody = response.body().asUtf8String()
Expand All @@ -91,4 +125,32 @@ class Bedrock(config: CommonConfig)
embedding
}
}

override def createImageEmbeddings(source: Array[Byte], mimeType: MimeType, maybeMetadata: Option[ImageMetadata])(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Embedding] = {
val base64ImageData = Base64.encodeBase64String(source)
val requestBody = createImageSearchDocumentRequestBody(
base64ImageData, embeddingSourceImageFormat().format
)
val bedrockFuture = Future {
sendBedrockEmbeddingRequest(requestBody)
}
bedrockFuture.map { response =>
val responseBody = response.body().asUtf8String()
val json = Json.parse(responseBody)
// Extract the embedding array (first element since it's an array of arrays)
val embeddings = (json \ "embeddings" \ "float")(0).as[List[Double]]
logger.info(
logMarker,
s"Successfully created image embedding. Vector size: ${embeddings.size}"
)
embeddings
}.map { embeddings =>
Embedding(
cohereEmbedV4 = Some(CohereV4Embedding(embeddings))
)
}
}

override def embeddingSourceImageFormat(): EmbeddingSourceImageFormat = EmbeddingSourceImageFormat(longestAxis = 3000, format = Jpeg, letterBox = false)

}
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
package com.gu.mediaservice.lib.aws
import com.gu.mediaservice.lib.embeddings.{EmbeddingImplementation, EmbeddingSourceImageFormat}
import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker}
import com.gu.mediaservice.model.{Jpeg, MimeType, Png, Tiff}
import com.gu.mediaservice.model.{Embedding, ImageMetadata, MimeType}
import play.api.libs.json.{Json, OFormat}
import software.amazon.awssdk.services.s3vectors.model.QueryVectorsResponse
import software.amazon.awssdk.services.s3vectors.model.{QueryOutputVector, QueryVectorsResponse, VectorData}
import software.amazon.awssdk.services.sqs.model.SendMessageResponse

import java.nio.file.{Files, Path}
import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters.CollectionHasAsScala

case class EmbedderMessage(imageId: String, fileType: String, s3Bucket: String, s3Key: String, instance: String)
case class EmbedderMessage(imageId: String, s3Bucket: String, s3Key: String, instance: String, imageMetadata: Option[ImageMetadata])

object EmbedderMessage {
implicit val format: OFormat[EmbedderMessage] = Json.format[EmbedderMessage]
}

class Embedder(bedrock: Bedrock, sqs: SimpleSqsMessageConsumer)(implicit ec: ExecutionContext) extends GridLogging {
class Embedder(embedding: EmbeddingImplementation, sqs: SimpleSqsMessageConsumer)(implicit ec: ExecutionContext) extends GridLogging {

def createQueryEmbedding(query: String)(implicit logMarker: LogMarker): Future[List[Double]] = {
logger.info(logMarker, s"Creating text embedding for query: $query")
for {
embedding <- bedrock.createTextEmbedding(query)
embedding <- embedding.createTextEmbedding(query)
} yield embedding
}

def queueImageToEmbed(message: EmbedderMessage)(implicit logMarker: LogMarker) = {
def createImageEmbedding(source: Array[Byte], mimeType: MimeType, maybeMetadata: Option[ImageMetadata])(implicit logMarker: LogMarker): Future[Embedding] = {
logger.info(logMarker, s"Creating image embedding")
embedding.createImageEmbeddings(source, mimeType, maybeMetadata)
}

def queueImageToEmbed(message: EmbedderMessage)(implicit logMarker: LogMarker): Unit = {
val messageBody = Json.stringify(Json.toJson(message))
val result: SendMessageResponse = sqs.sendMessage(messageBody)
logger.info(logMarker, s"Queued image for embedding with message ID: ${result.messageId()}")
}

def embeddingSourceImageFormat(): EmbeddingSourceImageFormat = embedding.embeddingSourceImageFormat()

}
Loading
Loading