packages feed

shikumi-0.2.0.0: test/ProgramAcceptanceSpec.hs

-- | EP-4 M5 — the end-to-end acceptance behaviors named in the plan's Purpose:
--
--   * __typed composition runs end-to-end__: two record-typed signatures composed
--     with 'pipeline' run through the stub @LLM@ and return a typed @Draft@;
--   * __the program is rewritable as data, and the rewrite reaches the wire__:
--     editing node 0's instruction with 'mapParamsAt' shows the new instruction in
--     both @foldParams@ (as data) and in the captured first-stage prompt (on the
--     wire), while node 1 is untouched.
module ProgramAcceptanceSpec (tests) where

import Control.Lens ((&), (.~))
import Data.Aeson (Value, object, (.=))
import Data.Generics.Labels ()
import Data.IORef (newIORef, readIORef)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (runEff)
import Effectful.Error.Static (runErrorNoCallStack)
import ProgramFixtures
  ( Draft (..),
    Topic (..),
    draftResponse,
    outlineResponse,
    outlineToDraft,
    runRecordingLLM,
    runScriptedLLM,
    topicToOutline,
  )
import Shikumi.Error (ShikumiError)
import Shikumi.Module (predict)
import Shikumi.Program
  ( Demo (..),
    Params (..),
    Program,
    foldParams,
    mapParamsAt,
    pipeline,
    runProgram,
  )
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))

-- The headline two-stage program: Topic -> Outline -> Draft.
essay :: Program Topic Draft
essay = pipeline (predict topicToOutline) (predict outlineToDraft)

-- A JSON demo whose halves decode to a Topic and an Outline (so it can be spliced
-- into the rendered prompt without error).
sampleDemo :: Demo
sampleDemo =
  Demo
    { input = topicJSON "demo topic",
      output = outlineJSON ["alpha", "beta"]
    }

topicJSON :: Text -> Value
topicJSON s = object ["subject" .= s]

outlineJSON :: [Text] -> Value
outlineJSON ps = object ["points" .= ps]

tests :: TestTree
tests =
  testGroup
    "ProgramAcceptanceSpec"
    [ testCase "Behavior 1: a typed two-stage pipeline returns a Draft" $ do
        ref <- newIORef [outlineResponse, draftResponse]
        out <-
          runEff . runErrorNoCallStack @ShikumiError . runScriptedLLM ref $
            runProgram essay (Topic "haskell")
        out @?= Right (Draft {prose = "A fine essay about the outline."}),
      testCase "Behavior 2 (data): rewriting node 0 changes only node 0's params" $ do
        let rewritten =
              mapParamsAt
                0
                (\p -> p & #instructionOverride .~ Just "NEW INSTRUCTION" & #demos .~ [sampleDemo])
                essay
            params = foldParams rewritten
        length params @?= 2
        instructionOverride (params !! 0) @?= Just "NEW INSTRUCTION"
        demos (params !! 0) @?= [sampleDemo]
        params !! 1 @?= emptyParamsLike,
      testCase "Behavior 2 (wire): the new instruction appears in the captured first-stage prompt" $ do
        capture <- newIORef []
        ref <- newIORef [outlineResponse, draftResponse]
        let rewritten =
              mapParamsAt
                0
                (\p -> p & #instructionOverride .~ Just "NEW INSTRUCTION" & #demos .~ [sampleDemo])
                essay
        out <-
          runEff . runErrorNoCallStack @ShikumiError . runRecordingLLM capture ref $
            runProgram rewritten (Topic "haskell")
        out @?= Right (Draft {prose = "A fine essay about the outline."})
        prompts <- readIORef capture
        case prompts of
          (firstStage : _) -> do
            length prompts @?= 2
            assertBool
              "first-stage prompt should contain the new instruction"
              ("NEW INSTRUCTION" `T.isInfixOf` firstStage)
          [] -> assertBool "expected captured prompts" False
    ]
  where
    -- node 1 should remain the default overlay
    emptyParamsLike :: Params
    emptyParamsLike = Params Nothing []