diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb00b25 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,243 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Continuous Integration + +on: + pull_request: + branches: ['**', '!update/**', '!pr/**'] + push: + branches: ['**', '!update/**', '!pr/**'] + tags: [v*] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + +concurrency: + group: ${{ github.workflow }} @ ${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Test + strategy: + matrix: + os: [ubuntu-22.04] + scala: [3] + java: [temurin@17] + project: [rootJS, rootJVM, rootNative] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup sbt + uses: sbt/setup-sbt@v1 + + - name: Setup Java (temurin@17) + id: setup-java-temurin-17 + if: matrix.java == 'temurin@17' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update + + - name: Check that workflows are up to date + run: sbt githubWorkflowCheck + + - name: Check headers and formatting + if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck + + - name: scalaJSLink + if: matrix.project == 'rootJS' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/scalaJSLinkerResult + + - name: nativeLink + if: matrix.project == 'rootNative' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/nativeLink + + - name: Test + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test + + - name: Check binary compatibility + if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues + + - name: Generate API documentation + if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc + + - name: Make target directories + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + run: mkdir -p core/.native/target core/.js/target core/.jvm/target project/target + + - name: Compress target directories + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + run: tar cf targets.tar core/.native/target core/.js/target core/.jvm/target project/target + + - name: Upload target directories + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + uses: actions/upload-artifact@v5 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.project }} + path: targets.tar + + publish: + name: Publish Artifacts + needs: [build] + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + strategy: + matrix: + os: [ubuntu-22.04] + java: [temurin@17] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup sbt + uses: sbt/setup-sbt@v1 + + - name: Setup Java (temurin@17) + id: setup-java-temurin-17 + if: matrix.java == 'temurin@17' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update + + - name: Download target directories (3, rootJS) + uses: actions/download-artifact@v6 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJS + + - name: Inflate target directories (3, rootJS) + run: | + tar xf targets.tar + rm targets.tar + + - name: Download target directories (3, rootJVM) + uses: actions/download-artifact@v6 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJVM + + - name: Inflate target directories (3, rootJVM) + run: | + tar xf targets.tar + rm targets.tar + + - name: Download target directories (3, rootNative) + uses: actions/download-artifact@v6 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootNative + + - name: Inflate target directories (3, rootNative) + run: | + tar xf targets.tar + rm targets.tar + + - name: Import signing key + if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' + env: + PGP_SECRET: ${{ secrets.PGP_SECRET }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + run: echo $PGP_SECRET | base64 -d -i - | gpg --import + + - name: Import signing key and strip passphrase + if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' + env: + PGP_SECRET: ${{ secrets.PGP_SECRET }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + run: | + echo "$PGP_SECRET" | base64 -d -i - > /tmp/signing-key.gpg + echo "$PGP_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --import /tmp/signing-key.gpg + (echo "$PGP_PASSPHRASE"; echo; echo) | gpg --command-fd 0 --pinentry-mode loopback --change-passphrase $(gpg --list-secret-keys --with-colons 2> /dev/null | grep '^sec:' | cut --delimiter ':' --fields 5 | tail -n 1) + + - name: Publish + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} + run: sbt tlCiRelease + + dependency-submission: + name: Submit Dependencies + if: github.event.repository.fork == false && github.event_name != 'pull_request' + strategy: + matrix: + os: [ubuntu-22.04] + java: [temurin@17] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup sbt + uses: sbt/setup-sbt@v1 + + - name: Setup Java (temurin@17) + id: setup-java-temurin-17 + if: matrix.java == 'temurin@17' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update + + - name: Submit Dependencies + uses: scalacenter/sbt-dependency-submission@v2 + with: + modules-ignore: rootjs_3 rootjvm_3 rootnative_3 + configs-ignore: test scala-tool scala-doc-tool test-internal + + validate-steward: + name: Validate Steward Config + strategy: + matrix: + os: [ubuntu-22.04] + java: [temurin@17] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (fast) + uses: actions/checkout@v6 + + - name: Setup Java (temurin@17) + id: setup-java-temurin-17 + if: matrix.java == 'temurin@17' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - uses: coursier/setup-action@v1 + with: + apps: scala-steward + + - run: scala-steward validate-repo-config .scala-steward.conf diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml new file mode 100644 index 0000000..547aaa4 --- /dev/null +++ b/.github/workflows/clean.yml @@ -0,0 +1,59 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Clean + +on: push + +jobs: + delete-artifacts: + name: Delete Artifacts + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Delete artifacts + run: | + # Customize those three lines with your repository and credentials: + REPO=${GITHUB_API_URL}/repos/${{ github.repository }} + + # A shortcut to call GitHub API. + ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } + + # A temporary file which receives HTTP response headers. + TMPFILE=/tmp/tmp.$$ + + # An associative array, key: artifact name, value: number of artifacts of that name. + declare -A ARTCOUNT + + # Process all artifacts on this repository, loop on returned "pages". + URL=$REPO/actions/artifacts + while [[ -n "$URL" ]]; do + + # Get current page, get response headers in a temporary file. + JSON=$(ghapi --dump-header $TMPFILE "$URL") + + # Get URL of next page. Will be empty if we are at the last page. + URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') + rm -f $TMPFILE + + # Number of artifacts on this page: + COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) + + # Loop on all artifacts on this page. + for ((i=0; $i < $COUNT; i++)); do + + # Get name of artifact and count instances of this name. + name=$(jq <<<$JSON -r ".artifacts[$i].name?") + ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) + + id=$(jq <<<$JSON -r ".artifacts[$i].id?") + size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) + printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size + ghapi -X DELETE $REPO/actions/artifacts/$id + done + done diff --git a/.gitignore b/.gitignore index eefaae8..fea82ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +.direnv/ .scala-build/ +**/target .bsp/ +node_modules/ .metals/ +.bloop/ +metals.sbt diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..883e699 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,27 @@ +# This file was automatically generated by sbt-typelevel-mergify using the +# mergifyGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the mergify configuration +# to meet your needs, then regenerate this file. + +pull_request_rules: +- name: merge scala-steward's PRs + conditions: + - author=scala-steward + - or: + - body~=labels:.*early-semver-patch + - body~=labels:.*early-semver-minor + - status-success=Test (ubuntu-22.04, 3, temurin@17, rootJS) + - status-success=Test (ubuntu-22.04, 3, temurin@17, rootJVM) + - status-success=Test (ubuntu-22.04, 3, temurin@17, rootNative) + actions: + merge: {} +- name: Label core PRs + conditions: + - files~=^core/ + actions: + label: + add: + - core + remove: [] diff --git a/.scala-steward.conf b/.scala-steward.conf new file mode 100644 index 0000000..3e2ad3a --- /dev/null +++ b/.scala-steward.conf @@ -0,0 +1,3 @@ +updates.cooldown = { + minimumAge = "7 days" +} diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..23dbe0f --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,36 @@ +version = 3.11.1 +runner.dialect=scala3 + +runner.dialectOverride.allowSignificantIndentation = false +runner.dialectOverride.allowQuietSyntax = true + +maxColumn = 100 +align.preset = some + +newlines.beforeMultiline = unfold +newlines.topLevelStatements = [before, after] +newlines.topLevelStatementsMinBreaks = 2 +newlines.implicitParamListModifierForce = [before] +continuationIndent.defnSite = 2 +continuationIndent.extendSite = 2 +optIn.breakChainOnFirstMethodDot = true +includeCurlyBraceInSelectChains = true +includeNoParensInSelectChains = true + +trailingCommas = "multiple" + +rewrite.rules = [ + RedundantBraces, + RedundantParens, + ExpandImportSelectors, + PreferCurlyFors +] + +runner.optimizer.forceConfigStyleMinArgCount = 3 +danglingParentheses.defnSite = true +danglingParentheses.callSite = true +danglingParentheses.exclude = [ + "`trait`" +] +verticalMultiline.newlineAfterOpenParen = true +verticalMultiline.atDefnSite = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4bf9933 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + Copyright 2026 Polyvariant + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..684ce85 --- /dev/null +++ b/build.sbt @@ -0,0 +1,50 @@ +ThisBuild / tlBaseVersion := "0.1" +ThisBuild / organization := "org.polyvariant" +ThisBuild / organizationName := "Polyvariant" +ThisBuild / startYear := Some(2026) +ThisBuild / licenses := Seq(License.Apache2) +ThisBuild / developers := List(tlGitHubDev("kubukoz", "Jakub Kozłowski")) + +ThisBuild / githubWorkflowPublishTargetBranches := Seq( + RefPredicate.Equals(Ref.Branch("main")), + RefPredicate.StartsWith(Ref.Tag("v")), +) + +ThisBuild / scalaVersion := "3.9.0" +ThisBuild / tlJdkRelease := Some(17) +ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec.temurin("17")) +ThisBuild / tlFatalWarnings := false + +ThisBuild / mergifyStewardConfig ~= (_.map(_.withMergeMinors(true))) + +val hearthVersion = "0.4.2" +val kindlingsVersion = "0.3.2" +val http4sVersion = "0.23.37" +val circeVersion = "0.14.16" + +lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("core")) + .settings( + name := "jev4s", + scalacOptions ++= Seq( + "-no-indent", + "-Wunused:all", + ), + addCompilerPlugin("com.kubuszok" %% "hearth-cross-quotes" % hearthVersion), + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % "3.7.1", + "org.typelevel" %%% "cats-free" % "2.13.0", + "org.http4s" %%% "http4s-client" % http4sVersion, + "org.http4s" %%% "http4s-circe" % http4sVersion, + "io.circe" %%% "circe-core" % circeVersion, + "com.kubuszok" %%% "hearth" % hearthVersion, + "com.kubuszok" %%% "kindlings-derivation-commons" % kindlingsVersion, + "com.kubuszok" %%% "kindlings-circe-derivation" % kindlingsVersion, + "org.typelevel" %%% "munit-cats-effect" % "2.2.1" % Test, + "org.http4s" %%% "http4s-ember-client" % http4sVersion % Test, + "io.circe" %%% "circe-parser" % circeVersion % Test, + ), + ) + +lazy val root = tlCrossRootProject.aggregate(core) diff --git a/src/jev4s/Jev.scala b/core/src/main/scala/jev4s/Jev.scala similarity index 64% rename from src/jev4s/Jev.scala rename to core/src/main/scala/jev4s/Jev.scala index 3844524..efec0ec 100644 --- a/src/jev4s/Jev.scala +++ b/core/src/main/scala/jev4s/Jev.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jev4s import cats.effect.Concurrent @@ -30,8 +46,9 @@ import scala.concurrent.duration.* /** A client for TypeSafe's System One models (Jev). */ trait Jev[F[_]] { - /** Evaluates every question in `question` against `state` in one request. `state` is any JSON-encodable value: - * a String for plain text, or a case class / map / list for structured context. + /** Evaluates every question in `question` against `state` in one request. `state` is any + * JSON-encodable value: a String for plain text, or a case class / map / list for structured + * context. */ def evaluate[S: Encoder, A](state: S, question: Question[A]): F[Evaluation[A]] @@ -57,7 +74,9 @@ object JevConfig { val disabled: RetryConfig = RetryConfig(maxRetries = 0, maxBackoff = Duration.Zero) } - /** Reads `TYPESAFE_API_KEY` (required), `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`, like the official SDKs. */ + /** Reads `TYPESAFE_API_KEY` (required), `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`, like + * the official SDKs. + */ def fromEnv[F[_]: Env: Concurrent]: F[JevConfig] = ( Env[F].get("TYPESAFE_API_KEY").flatMap(_.liftTo[F](JevError.MissingApiKey)), @@ -70,6 +89,7 @@ object JevConfig { model = model.fold(base.model)(ModelId(_)), ) } + } enum JevError(message: String) extends Exception(message) { @@ -79,25 +99,37 @@ enum JevError(message: String) extends Exception(message) { case Unprocessable(body: Json) extends JevError(s"Request failed validation: ${body.noSpaces}") case RateLimited(body: String) extends JevError(s"Rate limited: $body") case Overloaded(body: String) extends JevError(s"Overloaded: $body") - case UnexpectedStatus(status: Status, body: String) extends JevError(s"Unexpected status $status: $body") + case UnexpectedStatus(status: Status, body: String) + extends JevError(s"Unexpected status $status: $body") case UnexpectedAnswer(reason: String) extends JevError(s"Unexpected answer: $reason") } object Jev { - /** Timeouts, connection pooling etc. are up to the `Client` you provide; retries are added on top of it. */ - def instance[F[_]: Temporal](config: JevConfig)(using client: Client[F]): Jev[F] = + /** Timeouts, connection pooling etc. are up to the `Client` you provide; retries are added on top + * of it. + */ + def instance[F[_]: Temporal]( + config: JevConfig + )( + using client: Client[F] + ): Jev[F] = JevImpl(config, withRetries(config.retry, client)) // Same statuses as the official SDKs: 408, 429, 5xx (529 included). Retry-After is honored by the middleware. - private def withRetries[F[_]: Temporal](config: JevConfig.RetryConfig, client: Client[F]): Client[F] = - if (config.maxRetries <= 0) client + private def withRetries[F[_]: Temporal](config: JevConfig.RetryConfig, client: Client[F]) + : Client[F] = + if (config.maxRetries <= 0) + client else Retry[F]( RetryPolicy( RetryPolicy.exponentialBackoff(config.maxBackoff, config.maxRetries), (_, result) => - result.fold(_ => true, r => r.status.code == 408 || r.status.code == 429 || r.status.code >= 500), + result.fold( + _ => true, + r => r.status.code == 408 || r.status.code == 429 || r.status.code >= 500, + ), ), Headers.SensitiveHeaders.contains, )(client) @@ -107,9 +139,17 @@ object Jev { def evaluate[S: Encoder, A](state: S, question: Question[A]): F[Evaluation[A]] = for { - body <- Question.requestBody(state, question, config.model).leftMap(JevError.InvalidQuestion(_)).liftTo[F] - response <- client.run(authorized(Method.POST, "v1/systemone").withEntity(body)).use(decodeOrFail[ResponseBody]) - evaluation <- Question.decodeResponse(question, response).leftMap(JevError.UnexpectedAnswer(_)).liftTo[F] + body <- Question + .requestBody(state, question, config.model) + .leftMap(JevError.InvalidQuestion(_)) + .liftTo[F] + response <- client + .run(authorized(Method.POST, "v1/systemone").withEntity(body)) + .use(decodeOrFail[ResponseBody]) + evaluation <- Question + .decodeResponse(question, response) + .leftMap(JevError.UnexpectedAnswer(_)) + .liftTo[F] } yield evaluation def models: F[List[ModelCard]] = @@ -127,13 +167,17 @@ object Jev { private def decodeOrFail[A: Decoder](response: Response[F]): F[A] = { given EntityDecoder[F, A] = jsonOf[F, A] response.status match { - case s if s.isSuccess => response.as[A] - case Status.Unauthorized => response.as[String].flatMap(JevError.Unauthorized(_).raiseError) - case Status.UnprocessableContent => response.as[Json].flatMap(JevError.Unprocessable(_).raiseError) - case Status.TooManyRequests => response.as[String].flatMap(JevError.RateLimited(_).raiseError) - case s if s.code == 529 => response.as[String].flatMap(JevError.Overloaded(_).raiseError) - case s => response.as[String].flatMap(JevError.UnexpectedStatus(s, _).raiseError) + case s if s.isSuccess => response.as[A] + case Status.Unauthorized => response.as[String].flatMap(JevError.Unauthorized(_).raiseError) + case Status.UnprocessableContent => + response.as[Json].flatMap(JevError.Unprocessable(_).raiseError) + case Status.TooManyRequests => + response.as[String].flatMap(JevError.RateLimited(_).raiseError) + case s if s.code == 529 => response.as[String].flatMap(JevError.Overloaded(_).raiseError) + case s => response.as[String].flatMap(JevError.UnexpectedStatus(s, _).raiseError) } } + } + } diff --git a/src/jev4s/Options.scala b/core/src/main/scala/jev4s/Options.scala similarity index 66% rename from src/jev4s/Options.scala rename to core/src/main/scala/jev4s/Options.scala index e4d0558..8238632 100644 --- a/src/jev4s/Options.scala +++ b/core/src/main/scala/jev4s/Options.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jev4s import cats.data.NonEmptyVector @@ -8,8 +24,8 @@ import jev4s.internal.OptionsMacros /** The possible answers of a Choice, or the ordered levels of a Score. * - * For a Choice, `label` is the option key sent to the model (it carries meaning, unlike question IDs). For a - * Score, `values` must be ordered from lowest to highest level. + * For a Choice, `label` is the option key sent to the model (it carries meaning, unlike question + * IDs). For a Score, `values` must be ordered from lowest to highest level. */ trait Options[A] { def values: NonEmptyVector[A] @@ -18,14 +34,22 @@ trait Options[A] { final def labelledBy(f: A => String): Options[A] = Options.instance(values, f, description) - final def describedBy[D: Encoder](f: A => D): Options[A] = Options.instance(values, label, a => Some(f(a).asJson)) + final def describedBy[D: Encoder](f: A => D): Options[A] = Options.instance( + values, + label, + a => Some(f(a).asJson), + ) + } object Options { - def apply[A](using o: Options[A]): Options[A] = o + def apply[A]( + using o: Options[A] + ): Options[A] = o - def instance[A](values: NonEmptyVector[A], label: A => String, description: A => Option[Json]): Options[A] = { + def instance[A](values: NonEmptyVector[A], label: A => String, description: A => Option[Json]) + : Options[A] = { val (v, l, d) = (values, label, description) new Options[A] { val values: NonEmptyVector[A] = v @@ -47,8 +71,8 @@ object Options { /** Derives options for an enum (or sealed trait) of parameterless cases, in declaration order. * - * Each case is labelled by its name unless annotated with [[label]], and described by its [[description]] - * annotation if present. Also usable as `derives Options`. + * Each case is labelled by its name unless annotated with [[label]], and described by its + * [[description]] annotation if present. Also usable as `derives Options`. */ inline def derived[A]: Options[A] = ${ OptionsMacros.derive[A] } @@ -63,6 +87,11 @@ object Options { /** Score levels given by position: level `i` is described by the `i`-th string. */ def levels(first: String, second: String, rest: String*): Options[Int] = { val all = first +: second +: rest.toVector - instance(NonEmptyVector.fromVectorUnsafe(all.indices.toVector), all(_), i => Some(all(i).asJson)) + instance( + NonEmptyVector.fromVectorUnsafe(all.indices.toVector), + all(_), + i => Some(all(i).asJson), + ) } + } diff --git a/src/jev4s/Question.scala b/core/src/main/scala/jev4s/Question.scala similarity index 72% rename from src/jev4s/Question.scala rename to core/src/main/scala/jev4s/Question.scala index b964fad..05b6107 100644 --- a/src/jev4s/Question.scala +++ b/core/src/main/scala/jev4s/Question.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jev4s import cats.Applicative @@ -17,7 +33,9 @@ import jev4s.internal.ResponseBody import scala.collection.immutable.ListMap -/** Answer to a yes/no question. There is no separate confidence: 0.5 means "yes and no equally likely". */ +/** Answer to a yes/no question. There is no separate confidence: 0.5 means "yes and no equally + * likely". + */ final case class Noul(yes: Probability) { def no: Probability = yes.complement } @@ -25,11 +43,17 @@ final case class Noul(yes: Probability) { final case class Choice[A](choice: A, probabilities: Map[A, Probability], confidence: Confidence) /** `score` is the probability-weighted level index, and can land between levels. */ -final case class Score[A](score: Double, probabilities: Map[A, Probability], confidence: Confidence) { +final case class Score[A]( + score: Double, + probabilities: Map[A, Probability], + confidence: Confidence, +) { def mostLikely: A = probabilities.maxBy(_._2)._1 } -/** A single question: what goes over the wire, and how to read its answer. Lifted into [[Question]]. */ +/** A single question: what goes over the wire, and how to read its answer. Lifted into + * [[Question]]. + */ private[jev4s] final case class Ask[A]( spec: QuestionSpec, decode: RawAnswer => Either[String, A], @@ -39,9 +63,9 @@ private[jev4s] final case class Ask[A]( /** One or more questions about the same state, whose combined answer is an `A`. * - * Compose with `mapN`/`tupled`/`traverse`: all questions in a `Question` go out in a single request and are - * evaluated in parallel, independently of each other. Question IDs are assigned by the library (the model never - * sees them), so answers can't be looked up under the wrong key. + * Compose with `mapN`/`tupled`/`traverse`: all questions in a `Question` go out in a single + * request and are evaluated in parallel, independently of each other. Question IDs are assigned by + * the library (the model never sees them), so answers can't be looked up under the wrong key. */ opaque type Question[A] = FreeApplicative[Ask, A] @@ -50,21 +74,28 @@ object Question { given Applicative[Question] = summon[Applicative[FreeApplicative[Ask, *]]] def noul[I: Encoder](instructions: I): Question[Noul] = - single(QuestionSpec.Noul(instructions.asJson, None)) { case RawAnswer.Noul(p) => Right(Noul(p)) } + single(QuestionSpec.Noul(instructions.asJson, None)) { case RawAnswer.Noul(p) => + Right(Noul(p)) + } /** A Noul with descriptions of what "yes" and "no" mean. */ def noul[I: Encoder, Y: Encoder, N: Encoder](instructions: I, yes: Y, no: N): Question[Noul] = - single(QuestionSpec.Noul(instructions.asJson, Some(NoulCriteria(Some(yes.asJson), Some(no.asJson))))) { - case RawAnswer.Noul(p) => Right(Noul(p)) - } + single( + QuestionSpec.Noul(instructions.asJson, Some(NoulCriteria(Some(yes.asJson), Some(no.asJson)))) + ) { case RawAnswer.Noul(p) => Right(Noul(p)) } /** Picks one of `A`'s options, e.g. the cases of an enum with a `given Options`. */ - def choice[A](using options: Options[A]): ChoicePartiallyApplied[A] = ChoicePartiallyApplied(options) + def choice[A]( + using options: Options[A] + ): ChoicePartiallyApplied[A] = ChoicePartiallyApplied(options) /** Rates the state on `A`'s levels, lowest first. */ - def score[A](using levels: Options[A]): ScorePartiallyApplied[A] = ScorePartiallyApplied(levels) + def score[A]( + using levels: Options[A] + ): ScorePartiallyApplied[A] = ScorePartiallyApplied(levels) final class ChoicePartiallyApplied[A] private[Question] (options: Options[A]) { + def apply[I: Encoder](instructions: I): Question[Choice[A]] = { val labels = options.values.toVector.map(options.label) val byLabel = labels.zip(options.values.toVector).toMap @@ -73,7 +104,8 @@ object Question { ListMap.from(options.values.toVector.map(a => options.label(a) -> options.description(a))), ) val duplicates = labels.diff(labels.distinct).distinct - val problems = Option.when(duplicates.nonEmpty)(s"Duplicate Choice labels: ${duplicates.mkString(", ")}") + val problems = + Option.when(duplicates.nonEmpty)(s"Duplicate Choice labels: ${duplicates.mkString(", ")}") single(spec, problems.toVector) { case RawAnswer.Choice(choice, probabilities, confidence) => ( lookup(byLabel, choice), @@ -81,9 +113,11 @@ object Question { ).mapN(Choice(_, _, confidence)) } } + } final class ScorePartiallyApplied[A] private[Question] (levels: Options[A]) { + def apply[I: Encoder](instructions: I): Question[Score[A]] = { val byIndex = levels.values.toVector.zipWithIndex.map((a, i) => i.toString -> a).toMap // Score criteria are descriptions only; fall back to the label when a level has none. @@ -92,21 +126,31 @@ object Question { levels.values.toVector.map(a => levels.description(a).getOrElse(levels.label(a).asJson)), ) single(spec) { case RawAnswer.Score(score, probabilities, confidence) => - probabilities.toList + probabilities + .toList .traverse((k, p) => lookup(byIndex, k).tupleRight(p)) .map(ps => Score(score, ps.toMap, confidence)) } } + } private def lookup[A](m: Map[String, A], key: String): Either[String, A] = m.get(key).toRight(s"Unexpected option in answer: $key") - private def single[A](spec: QuestionSpec, problems: Vector[String] = Vector.empty)( + private def single[A]( + spec: QuestionSpec, + problems: Vector[String] = Vector.empty, + )( f: PartialFunction[RawAnswer, Either[String, A]] ): Question[A] = FreeApplicative.lift( - Ask(spec, raw => f.applyOrElse(raw, other => Left(s"Answer type mismatch: expected $spec, got $other")), problems) + Ask( + spec, + raw => + f.applyOrElse(raw, other => Left(s"Answer type mismatch: expected $spec, got $other")), + problems, + ) ) private def idOf(index: Int): String = s"q$index" @@ -125,7 +169,8 @@ object Question { }) .getConst - private[jev4s] def requestBody[S: Encoder, A](state: S, question: Question[A], model: ModelId): Either[String, Json] = { + private[jev4s] def requestBody[S: Encoder, A](state: S, question: Question[A], model: ModelId) + : Either[String, Json] = { val all = specs(question) val definitionProblems = problems(question) for { @@ -142,11 +187,13 @@ object Question { // Walks the questions in the same order as `specs`, so the n-th question reads the answer under `idOf(n)`. private type Decoding[x] = StateT[Either[String, *], Int, x] - private[jev4s] def decodeResponse[A](question: Question[A], body: ResponseBody): Either[String, Evaluation[A]] = + private[jev4s] def decodeResponse[A](question: Question[A], body: ResponseBody) + : Either[String, Evaluation[A]] = question .foldMap(new (Ask ~> Decoding) { def apply[x](ask: Ask[x]): Decoding[x] = StateT { index => - body.answers + body + .answers .get(idOf(index)) .toRight(s"Missing answer for question ${idOf(index)}") .flatMap(ask.decode) @@ -155,4 +202,5 @@ object Question { }) .runA(0) .map(Evaluation(_, body.model, body.usage)) + } diff --git a/core/src/main/scala/jev4s/annotations.scala b/core/src/main/scala/jev4s/annotations.scala new file mode 100644 index 0000000..1d01153 --- /dev/null +++ b/core/src/main/scala/jev4s/annotations.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jev4s + +import scala.annotation.StaticAnnotation + +/** Overrides the option key sent to the model for an enum case. Defaults to the case name. */ +final class label(val value: String) extends StaticAnnotation + +/** Describes what an enum case means: a Choice option's rubric, or a Score level's description. */ +final class description(val value: String) extends StaticAnnotation diff --git a/core/src/main/scala/jev4s/internal/OptionsMacros.scala b/core/src/main/scala/jev4s/internal/OptionsMacros.scala new file mode 100644 index 0000000..15bb90e --- /dev/null +++ b/core/src/main/scala/jev4s/internal/OptionsMacros.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jev4s.internal + +import hearth.MacroCommonsScala3 +import hearth.kindlings.derivation.compiletime.AnnotationSupport +import jev4s.Options + +import scala.quoted.* + +final private[jev4s] class OptionsMacros(q: Quotes) + extends MacroCommonsScala3( + using q + ), + AnnotationSupport, + OptionsMacrosImpl + +private[jev4s] object OptionsMacros { + + def derive[A: Type]( + using q: Quotes + ): Expr[Options[A]] = new OptionsMacros(q).deriveOptions[A] + +} diff --git a/src/jev4s/internal/OptionsMacrosImpl.scala b/core/src/main/scala/jev4s/internal/OptionsMacrosImpl.scala similarity index 65% rename from src/jev4s/internal/OptionsMacrosImpl.scala rename to core/src/main/scala/jev4s/internal/OptionsMacrosImpl.scala index 7c641e4..d4607c9 100644 --- a/src/jev4s/internal/OptionsMacrosImpl.scala +++ b/core/src/main/scala/jev4s/internal/OptionsMacrosImpl.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jev4s.internal import hearth.* @@ -7,7 +23,9 @@ import jev4s.Options import jev4s.description import jev4s.label -/** Derives `Options[A]` for an enum or sealed trait of singleton cases, reading `@label` and `@description`. */ +/** Derives `Options[A]` for an enum or sealed trait of singleton cases, reading `@label` and + * `@description`. + */ private[jev4s] trait OptionsMacrosImpl { this: MacroCommons & StdExtensions & AnnotationSupport => def deriveOptions[A: Type]: Expr[Options[A]] = { @@ -17,7 +35,9 @@ private[jev4s] trait OptionsMacrosImpl { this: MacroCommons & StdExtensions & An implicit val OptionStringT: Type[Option[String]] = Type.of[Option[String]] val children = Type[A].directChildren.filter(_.nonEmpty).getOrElse { - Environment.reportErrorAndAbort(s"${Type[A].prettyPrint} is not an enum or sealed trait with at least one case") + Environment.reportErrorAndAbort( + s"${Type[A].prettyPrint} is not an enum or sealed trait with at least one case" + ) } val cases: List[Expr[(A, String, Option[String])]] = children.toList.map { (name, child) => @@ -36,4 +56,5 @@ private[jev4s] trait OptionsMacrosImpl { this: MacroCommons & StdExtensions & An Expr.quote(Options.fromCases[A](Expr.splice(VarArgs.from(cases))*)) } + } diff --git a/core/src/main/scala/jev4s/internal/wire.scala b/core/src/main/scala/jev4s/internal/wire.scala new file mode 100644 index 0000000..49efb46 --- /dev/null +++ b/core/src/main/scala/jev4s/internal/wire.scala @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package jev4s.internal + +import hearth.kindlings.circederivation.Configuration +import hearth.kindlings.circederivation.KindlingsDecoder +import hearth.kindlings.circederivation.KindlingsEncoder +import hearth.kindlings.circederivation.annotations.fieldName +import io.circe.Decoder +import io.circe.Encoder +import io.circe.Json +import jev4s.Confidence +import jev4s.ModelCard +import jev4s.ModelId +import jev4s.Probability +import jev4s.Usage + +import scala.collection.immutable.ListMap + +/** The API's JSON conventions: snake_case fields, and a lowercase `type` discriminator on + * questions/answers. + */ +private[jev4s] val wireConfig: Configuration = + Configuration() + .withSnakeCaseMemberNames + .withDiscriminator("type") + .withTransformConstructorNames(_.toLowerCase) + +/** One question as it goes over the wire, minus its ID. */ +private[jev4s] enum QuestionSpec { + case Noul(instructions: Json, criteria: Option[NoulCriteria]) + case Choice(instructions: Json, criteria: ListMap[String, Option[Json]]) + case Score(instructions: Json, criteria: Vector[Json]) + + def validate: Either[String, Unit] = + this match { + case Noul(_, _) => Right(()) + case Choice(_, options) => + Either.cond( + options.sizeIs <= 255, + (), + s"A Choice accepts at most 255 options, got ${options.size}", + ) + case Score(_, levels) => + Either.cond( + levels.sizeIs >= 2 && levels.sizeIs <= 10, + (), + s"A Score needs 2 to 10 levels, got ${levels.size}", + ) + } + +} + +private[jev4s] object QuestionSpec { + + given Encoder[QuestionSpec] = KindlingsEncoder.derived( + using wireConfig + ) + +} + +private[jev4s] final case class NoulCriteria( + @fieldName("true") yes: Option[Json], + @fieldName("false") no: Option[Json], +) + +private[jev4s] enum RawAnswer { + case Noul(noul: Probability) + case Choice(choice: String, probabilities: Map[String, Probability], confidence: Confidence) + case Score(score: Double, probabilities: Map[String, Probability], confidence: Confidence) +} + +private[jev4s] object RawAnswer { + + given Decoder[RawAnswer] = KindlingsDecoder.derived( + using wireConfig + ) + +} + +private[jev4s] final case class ResponseBody( + model: ModelId, + answers: Map[String, RawAnswer], + usage: Usage, +) + +private[jev4s] object ResponseBody { + + given Decoder[ResponseBody] = KindlingsDecoder.derived( + using wireConfig + ) + +} + +private[jev4s] final case class ModelList(models: List[ModelCard]) + +private[jev4s] object ModelList { + + given Decoder[ModelList] = KindlingsDecoder.derived( + using wireConfig + ) + +} diff --git a/src/jev4s/model.scala b/core/src/main/scala/jev4s/model.scala similarity index 72% rename from src/jev4s/model.scala rename to core/src/main/scala/jev4s/model.scala index acac128..1466b16 100644 --- a/src/jev4s/model.scala +++ b/core/src/main/scala/jev4s/model.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jev4s import cats.Order @@ -22,7 +38,10 @@ object Probability { def complement: Probability = 1.0 - p } - given Order[Probability] = Order.fromOrdering(using Ordering.Double.TotalOrdering) + given Order[Probability] = Order.fromOrdering( + using Ordering.Double.TotalOrdering + ) + given Ordering[Probability] = Ordering.Double.TotalOrdering given Decoder[Probability] = Decoder.decodeDouble.map(clamped) } @@ -39,7 +58,10 @@ object Confidence { def value: Double = c } - given Order[Confidence] = Order.fromOrdering(using Ordering.Double.TotalOrdering) + given Order[Confidence] = Order.fromOrdering( + using Ordering.Double.TotalOrdering + ) + given Ordering[Confidence] = Ordering.Double.TotalOrdering given Decoder[Confidence] = Decoder.decodeDouble.map(d => math.max(0.0, math.min(1.0, d))) } @@ -67,13 +89,21 @@ object ModelId { final case class Usage(inputTokens: Long, outputTokens: Long) object Usage { - given Decoder[Usage] = KindlingsDecoder.derived(using wireConfig) + + given Decoder[Usage] = KindlingsDecoder.derived( + using wireConfig + ) + } final case class ModelCard(name: ModelId, description: String, releaseDate: String) object ModelCard { - given Decoder[ModelCard] = KindlingsDecoder.derived(using wireConfig) + + given Decoder[ModelCard] = KindlingsDecoder.derived( + using wireConfig + ) + } /** The result of one evaluation: your typed answers plus request metadata. */ diff --git a/test/example/Triage.scala b/core/src/test/scala/example/Triage.scala similarity index 50% rename from test/example/Triage.scala rename to core/src/test/scala/example/Triage.scala index f93deb1..020c262 100644 --- a/test/example/Triage.scala +++ b/core/src/test/scala/example/Triage.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package example import cats.effect.IO @@ -36,16 +52,20 @@ object Triage { val topics: List[String] = List("refund", "account access", "data loss") - val question: Question[Triage] = ( - Question.noul( - "Does `message` convey urgency?", - yes = "Explicitly time-sensitive", - no = "No urgency expressed", - ), - Question.choice[Department]("Which team should handle this ticket?"), - Question.score[Frustration]("How frustrated is the customer?"), - topics.traverse(t => Question.noul(s"Does the ticket ask about $t?").tupleLeft(t)).map(_.toMap), - ).mapN(Triage.apply) + val question: Question[Triage] = + ( + Question.noul( + "Does `message` convey urgency?", + yes = "Explicitly time-sensitive", + no = "No urgency expressed", + ), + Question.choice[Department]("Which team should handle this ticket?"), + Question.score[Frustration]("How frustrated is the customer?"), + topics + .traverse(t => Question.noul(s"Does the ticket ask about $t?").tupleLeft(t)) + .map(_.toMap), + ).mapN(Triage.apply) + } object Main extends IOApp.Simple { @@ -61,11 +81,20 @@ object Main extends IOApp.Simple { Triage.question, ) triage = result.answers - _ <- IO.println(s"answered by ${result.model.value}, ${result.usage.inputTokens} input tokens") + _ <- IO.println( + s"answered by ${result.model.value}, ${result.usage.inputTokens} input tokens" + ) _ <- IO.println(s"urgent: ${triage.urgent.yes.value}") - _ <- IO.println(s"department: ${triage.department.choice} (confidence ${triage.department.confidence.value})") - _ <- IO.println(s"frustration: ${triage.frustration.score} ~ ${triage.frustration.mostLikely}") - _ <- IO.println(s"topics: ${triage.topics.filter(_._2.yes.value > 0.5).keys.mkString(", ")}") + _ <- IO.println( + s"department: ${triage.department.choice} (confidence ${triage.department.confidence.value})" + ) + _ <- IO.println( + s"frustration: ${triage.frustration.score} ~ ${triage.frustration.mostLikely}" + ) + _ <- IO.println( + s"topics: ${triage.topics.filter(_._2.yes.value > 0.5).keys.mkString(", ")}" + ) } yield () } + } diff --git a/test/JevTests.scala b/core/src/test/scala/jev4s/JevTests.scala similarity index 67% rename from test/JevTests.scala rename to core/src/test/scala/jev4s/JevTests.scala index dd23df8..eca5fac 100644 --- a/test/JevTests.scala +++ b/core/src/test/scala/jev4s/JevTests.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2026 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package jev4s import cats.effect.IO @@ -19,7 +35,10 @@ class JevTests extends CatsEffectSuite { private def fake(status: Status, body: Json): IO[(Ref[IO, List[(String, Json)]], Jev[IO])] = Ref[IO].of(List.empty[(String, Json)]).map { seen => given Client[IO] = Client.fromHttpApp(HttpApp[IO] { req => - req.as[Json].flatMap(j => seen.update(_ :+ (req.uri.renderString -> j))).as(Response[IO](status).withEntity(body)) + req + .as[Json] + .flatMap(j => seen.update(_ :+ (req.uri.renderString -> j))) + .as(Response[IO](status).withEntity(body)) }) (seen, Jev.instance[IO](config)) } @@ -77,7 +96,11 @@ class JevTests extends CatsEffectSuite { ) assertEquals( body.hcursor.downField("questions").downField("q3").focus, - Some(json("""{"type": "noul", "instructions": "Does the ticket ask about refund?", "criteria": null}""")), + Some( + json( + """{"type": "noul", "instructions": "Does the ticket ask about refund?", "criteria": null}""" + ) + ), ) } } @@ -86,39 +109,53 @@ class JevTests extends CatsEffectSuite { val options = Options.derived[Plan] assertEquals(options.values.toVector, Vector(Plan.Free, Plan.Pro)) assertEquals(options.values.toVector.map(options.label), Vector("Free", "paid")) - assertEquals(options.values.toVector.map(options.description), Vector(Some(Json.fromString("No payment")), None)) + assertEquals( + options.values.toVector.map(options.description), + Vector(Some(Json.fromString("No payment")), None), + ) } test("derivation rejects cases with fields") { assert( - compileErrors("Options.derived[WithFields]").contains("only parameterless cases can be options"), + compileErrors("Options.derived[WithFields]").contains( + "only parameterless cases can be options" + ), compileErrors("Options.derived[WithFields]"), ) } test("invalid questions fail before sending") { - val tooFewLevels = Question.score(using Options.derived[Single])("?") - fake(Status.Ok, Json.obj()).flatMap((seen, jev) => - jev.evaluate("x", tooFewLevels).attempt.product(seen.get) - ).map { (result, requests) => - assert(result.left.exists(_.isInstanceOf[JevError.InvalidQuestion]), result) - assertEquals(requests, Nil) - } + val tooFewLevels = + Question.score( + using Options.derived[Single] + )("?") + fake(Status.Ok, Json.obj()) + .flatMap((seen, jev) => jev.evaluate("x", tooFewLevels).attempt.product(seen.get)) + .map { (result, requests) => + assert(result.left.exists(_.isInstanceOf[JevError.InvalidQuestion]), result) + assertEquals(requests, Nil) + } } test("duplicate choice labels fail before sending") { - val question = Question.choice(using Options.labels("a", "b", "a"))("?") - fake(Status.Ok, Json.obj()).flatMap((seen, jev) => jev.evaluate("x", question).attempt.product(seen.get)).map { - (result, requests) => + val question = + Question.choice( + using Options.labels("a", "b", "a") + )("?") + fake(Status.Ok, Json.obj()) + .flatMap((seen, jev) => jev.evaluate("x", question).attempt.product(seen.get)) + .map { (result, requests) => assertEquals(result, Left(JevError.InvalidQuestion("Duplicate Choice labels: a"))) assertEquals(requests, Nil) - } + } } test("422 is surfaced with its body") { - fake(Status.UnprocessableContent, Json.obj("detail" -> Json.fromString("bad"))).flatMap((_, jev) => - jev.evaluate("x", Question.noul("?")).attempt - ).map(r => assertEquals(r, Left(JevError.Unprocessable(Json.obj("detail" -> Json.fromString("bad")))))) + fake(Status.UnprocessableContent, Json.obj("detail" -> Json.fromString("bad"))) + .flatMap((_, jev) => jev.evaluate("x", Question.noul("?")).attempt) + .map(r => + assertEquals(r, Left(JevError.Unprocessable(Json.obj("detail" -> Json.fromString("bad"))))) + ) } } @@ -127,6 +164,7 @@ enum Single { } sealed trait Plan + object Plan { @description("No payment") case object Free extends Plan @label("paid") case object Pro extends Plan diff --git a/project.scala b/project.scala deleted file mode 100644 index 7aa8db8..0000000 --- a/project.scala +++ /dev/null @@ -1,14 +0,0 @@ -//> using scala 3.9.0 -//> using options -deprecation -feature -Wunused:all -no-indent -Xkind-projector -//> using dep org.typelevel::cats-effect:3.7.1 -//> using dep org.typelevel::cats-free:2.13.0 -//> using dep org.http4s::http4s-client:0.23.37 -//> using dep org.http4s::http4s-circe:0.23.37 -//> using dep io.circe::circe-core:0.14.16 -//> using dep com.kubuszok::hearth:0.4.2 -//> using dep com.kubuszok::kindlings-derivation-commons:0.3.2 -//> using dep com.kubuszok::kindlings-circe-derivation:0.3.2 -//> using plugin com.kubuszok::hearth-cross-quotes:0.4.2 -//> using test.dep org.typelevel::munit-cats-effect:2.2.1 -//> using test.dep org.http4s::http4s-ember-client:0.23.37 -//> using test.dep io.circe::circe-parser:0.14.16 diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..7c95fc1 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.13 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..3c54962 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,5 @@ +addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.8.7") +addSbtPlugin("org.typelevel" % "sbt-typelevel-mergify" % "0.8.7") +// sbt-scalajs-crossproject and sbt-scala-native-crossproject are pulled in transitively by sbt-typelevel. +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12") diff --git a/src/jev4s/annotations.scala b/src/jev4s/annotations.scala deleted file mode 100644 index 4944142..0000000 --- a/src/jev4s/annotations.scala +++ /dev/null @@ -1,9 +0,0 @@ -package jev4s - -import scala.annotation.StaticAnnotation - -/** Overrides the option key sent to the model for an enum case. Defaults to the case name. */ -final class label(val value: String) extends StaticAnnotation - -/** Describes what an enum case means: a Choice option's rubric, or a Score level's description. */ -final class description(val value: String) extends StaticAnnotation diff --git a/src/jev4s/internal/OptionsMacros.scala b/src/jev4s/internal/OptionsMacros.scala deleted file mode 100644 index 9889cb9..0000000 --- a/src/jev4s/internal/OptionsMacros.scala +++ /dev/null @@ -1,16 +0,0 @@ -package jev4s.internal - -import hearth.MacroCommonsScala3 -import hearth.kindlings.derivation.compiletime.AnnotationSupport -import jev4s.Options - -import scala.quoted.* - -final private[jev4s] class OptionsMacros(q: Quotes) - extends MacroCommonsScala3(using q), - AnnotationSupport, - OptionsMacrosImpl - -private[jev4s] object OptionsMacros { - def derive[A: Type](using q: Quotes): Expr[Options[A]] = new OptionsMacros(q).deriveOptions[A] -} diff --git a/src/jev4s/internal/wire.scala b/src/jev4s/internal/wire.scala deleted file mode 100644 index 0dcfdb3..0000000 --- a/src/jev4s/internal/wire.scala +++ /dev/null @@ -1,66 +0,0 @@ -package jev4s.internal - -import hearth.kindlings.circederivation.Configuration -import hearth.kindlings.circederivation.KindlingsDecoder -import hearth.kindlings.circederivation.KindlingsEncoder -import hearth.kindlings.circederivation.annotations.fieldName -import io.circe.Decoder -import io.circe.Encoder -import io.circe.Json -import jev4s.Confidence -import jev4s.ModelCard -import jev4s.ModelId -import jev4s.Probability -import jev4s.Usage - -import scala.collection.immutable.ListMap - -/** The API's JSON conventions: snake_case fields, and a lowercase `type` discriminator on questions/answers. */ -private[jev4s] val wireConfig: Configuration = - Configuration() - .withSnakeCaseMemberNames - .withDiscriminator("type") - .withTransformConstructorNames(_.toLowerCase) - -/** One question as it goes over the wire, minus its ID. */ -private[jev4s] enum QuestionSpec { - case Noul(instructions: Json, criteria: Option[NoulCriteria]) - case Choice(instructions: Json, criteria: ListMap[String, Option[Json]]) - case Score(instructions: Json, criteria: Vector[Json]) - - def validate: Either[String, Unit] = this match { - case Noul(_, _) => Right(()) - case Choice(_, options) => - Either.cond(options.sizeIs <= 255, (), s"A Choice accepts at most 255 options, got ${options.size}") - case Score(_, levels) => - Either.cond(levels.sizeIs >= 2 && levels.sizeIs <= 10, (), s"A Score needs 2 to 10 levels, got ${levels.size}") - } -} - -private[jev4s] object QuestionSpec { - given Encoder[QuestionSpec] = KindlingsEncoder.derived(using wireConfig) -} - -private[jev4s] final case class NoulCriteria(@fieldName("true") yes: Option[Json], @fieldName("false") no: Option[Json]) - -private[jev4s] enum RawAnswer { - case Noul(noul: Probability) - case Choice(choice: String, probabilities: Map[String, Probability], confidence: Confidence) - case Score(score: Double, probabilities: Map[String, Probability], confidence: Confidence) -} - -private[jev4s] object RawAnswer { - given Decoder[RawAnswer] = KindlingsDecoder.derived(using wireConfig) -} - -private[jev4s] final case class ResponseBody(model: ModelId, answers: Map[String, RawAnswer], usage: Usage) - -private[jev4s] object ResponseBody { - given Decoder[ResponseBody] = KindlingsDecoder.derived(using wireConfig) -} - -private[jev4s] final case class ModelList(models: List[ModelCard]) - -private[jev4s] object ModelList { - given Decoder[ModelList] = KindlingsDecoder.derived(using wireConfig) -}