packages feed

kioku-core-0.4.0.0: src/Kioku/Distill/L3.hs

{-# LANGUAGE DataKinds #-}

module Kioku.Distill.L3
  ( L3Error (..),
    PersonaRow (..),
    fireL3PersonaTimer,
    getPersonaByScope,
    PersonaTimerPayload (..),
    l3PersonaProcessManagerName,
    l3PersonaTimerId,
    partitionedCorrelationId,
    mirrorPersonaToCurrentWorkspace,
    mirrorPersonaToWorkspace,
    personaMirrorPath,
    personaRowId,
    regeneratePersona,
    scheduleL3PersonaTimerTx,
  )
where

import Control.Exception (IOException, try)
import Crypto.Hash (Digest, SHA256)
import Crypto.Hash qualified as Hash
import Data.Aeson qualified as Aeson
import Data.Aeson.Types (withObject, (.:))
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Foldable (traverse_)
import Data.Functor.Contravariant ((>$<))
import Data.Int (Int32)
import Data.Maybe (catMaybes, maybeToList)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TextIO
import Data.Time (NominalDiffTime, addUTCTime)
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Data.UUID.V5 qualified as UUIDv5
import Effectful (Eff, IOE, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Hasql.Transaction qualified as Tx
import Keiro.Timer (TimerId (..), TimerRequest (..), TimerRow (..), scheduleTimerTx)
import Kioku.Api.Access
  ( MemoryContextProvider (..),
    MemorySpaceId,
    legacyMemorySpaceId,
    memoryContextSpace,
    memorySpaceIdText,
  )
import Kioku.Api.Scope (MemoryScope, scopeKindText, scopeNamespaceText, scopeRefText)
import Kioku.Database.Schema (personasTable, scenesTable)
import Kioku.Distill.Persona (PersonaInput (..), PersonaOutput (..))
import Kioku.Distill.Runtime (DistillRuntime, distillWorkspaceRoot, runPersonaDistillation)
import Kioku.Distill.ScopeIdentity (scopeIdentity, scopeSlugFromColumns)
import Kioku.Distill.Timer.Outcome (FireOutcome (..), fireRetryDelay, timerMarkerEventId)
import Kioku.Partition (memorySpaceColumn, memorySpaceParam, parsePartitionSpace)
import Kioku.Prelude
import Kioku.Workspace (legacyPersonaArtifactDir, personaArtifactDir)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import Shikumi.Schema.Types (field, unField)
import System.Directory (createDirectoryIfMissing, doesFileExist, getCurrentDirectory, removeFile)
import System.FilePath ((</>))

data L3Error
  = L3SceneGenerationUnavailable
  | L3PersonaGenerationFailed !Text
  deriving stock (Generic, Show)

data PersonaRow = PersonaRow
  { memorySpaceId :: !MemorySpaceId,
    personaId :: !Text,
    namespace :: !Text,
    scopeKind :: !(Maybe Text),
    scopeRef :: !(Maybe Text),
    bodyMd :: !Text,
    sceneCount :: !Int,
    sourceHash :: !Text,
    createdAt :: !UTCTime,
    updatedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data PersonaSceneRow = PersonaSceneRow
  { sceneId :: !Text,
    title :: !Text,
    bodyMd :: !Text,
    updatedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

-- | What a scheduled persona regeneration needs to know.
--
-- @memorySpaceId@ is what keeps two spaces that happen to share a namespace and scope from
-- regenerating each other's persona. Timers scheduled before the field existed decode into
-- 'Kioku.Api.Access.legacyMemorySpaceId', the same rule stored events follow.
data PersonaTimerPayload = PersonaTimerPayload
  { memorySpaceId :: !MemorySpaceId,
    scope :: !MemoryScope
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (ToJSON)

instance FromJSON PersonaTimerPayload where
  parseJSON =
    withObject "PersonaTimerPayload" \o ->
      PersonaTimerPayload <$> parsePartitionSpace o <*> o .: "scope"

l3PersonaProcessManagerName :: Text
l3PersonaProcessManagerName = "kioku-l3-persona"

personaDebounceSeconds :: NominalDiffTime
personaDebounceSeconds = 5

scheduleL3PersonaTimerTx :: MemorySpaceId -> MemoryScope -> UTCTime -> Tx.Transaction ()
scheduleL3PersonaTimerTx memorySpaceId scope now =
  scheduleTimerTx $
    TimerRequest
      { timerId = l3PersonaTimerId memorySpaceId scope fireAt,
        processManagerName = l3PersonaProcessManagerName,
        correlationId = partitionedCorrelationId memorySpaceId scope,
        fireAt,
        payload = Aeson.toJSON PersonaTimerPayload {memorySpaceId, scope}
      }
  where
    fireAt = addUTCTime personaDebounceSeconds now

-- | The timer id and correlation id both carry the memory space.
--
-- Unlike the L1 timers, which are keyed by a globally unique session id, these are keyed by a
-- scope — and two spaces are allowed to use the same one. Without the space in the id,
-- keiro's @scheduleTimerTx@ upsert would treat one space's regeneration as a re-arming of the
-- other's and only one payload would survive.
l3PersonaTimerId :: MemorySpaceId -> MemoryScope -> UTCTime -> TimerId
l3PersonaTimerId memorySpaceId scope fireAt =
  TimerId $
    UUIDv5.generateNamed
      l3PersonaTimerNamespace
      (BS.unpack (TE.encodeUtf8 raw))
  where
    raw =
      l3PersonaProcessManagerName
        <> ":"
        <> partitionedCorrelationId memorySpaceId scope
        <> ":"
        <> Text.pack (show fireAt)

-- | A scope identity qualified by its memory space. 'memorySpaceIdText' cannot contain @:@,
-- @\/@ or @%@ (see 'Kioku.Api.Access.mkMemorySpaceId') and 'scopeIdentity' escapes those same
-- characters, so joining the two with @:@ is injective.
partitionedCorrelationId :: MemorySpaceId -> MemoryScope -> Text
partitionedCorrelationId memorySpaceId scope =
  memorySpaceIdText memorySpaceId <> ":" <> scopeIdentity scope

regeneratePersona ::
  (IOE :> es, Store :> es) =>
  DistillRuntime ->
  MemorySpaceId ->
  MemoryScope ->
  Eff es (Either L3Error (Maybe PersonaRow))
regeneratePersona rt memorySpaceId scope = do
  scenes <- getPersonaScenesByScope memorySpaceId scope
  case scenes of
    -- Every scene in this scope is gone, so the persona distilled from them has
    -- no source left. Delete it and its mirror, symmetrically with the scene
    -- delete in "Kioku.Distill.L2", and without an LLM call: there is nothing
    -- to summarize. The persona is the top of the pyramid, so nothing chains on.
    [] -> do
      existing <- getPersonaByScope memorySpaceId scope
      case existing of
        Nothing -> pure (Right Nothing)
        Just row -> do
          runTransaction (Tx.statement (PersonaKey memorySpaceId row.personaId) deletePersonaStmt)
          liftIO (bestEffortRemovePersonaMirror rt row)
          pure (Right Nothing)
    _ -> do
      let sourceHash = personaSourceHash scenes
          personaId = personaRowId scope
      existing <- getPersonaByScope memorySpaceId scope
      case existing of
        Just row
          | row.sourceHash == sourceHash -> do
              liftIO (bestEffortMirrorPersona rt row)
              pure (Right (Just row))
        _ -> do
          outputResult <-
            liftIO $
              runPersonaDistillation
                rt
                PersonaInput
                  { scopeLabel = field (renderScope scope),
                    scenes = field (renderScenes scenes)
                  }
          case outputResult of
            Left err -> pure (Left (L3PersonaGenerationFailed (Text.pack (show err))))
            Right output -> do
              now <- liftIO getCurrentTime
              let row =
                    PersonaRow
                      { memorySpaceId,
                        personaId,
                        namespace = scopeNamespaceText scope,
                        scopeKind = scopeKindText scope,
                        scopeRef = scopeRefText scope,
                        bodyMd = unField output.bodyMd,
                        sceneCount = length scenes,
                        sourceHash,
                        createdAt = now,
                        updatedAt = now
                      }
              runTransaction (Tx.statement row upsertPersonaStmt)
              liftIO (bestEffortMirrorPersona rt row)
              pure (Right (Just row))

-- | Fire one L3 persona timer.
--
-- Like the L1 handler, a background pass cannot arrive holding an authorization context: it
-- reads the memory space out of the payload and asks the provider for a decision about /that/
-- space. A refusal is a configuration fact, so it dead-letters rather than retrying forever.
fireL3PersonaTimer ::
  (IOE :> es, Store :> es) =>
  MemoryContextProvider (Eff es) ->
  DistillRuntime ->
  TimerRow ->
  Eff es FireOutcome
fireL3PersonaTimer contexts rt row
  | row.processManagerName /= l3PersonaProcessManagerName =
      pure FireNotMine
  | otherwise =
      case Aeson.fromJSON @PersonaTimerPayload row.payload of
        -- Unparseable now, unparseable on every retry: dead-letter rather than
        -- mark it fired and lose the persona silently.
        Aeson.Error err ->
          pure (FireFailedPermanently ("L3 persona timer payload is malformed: " <> Text.pack err))
        Aeson.Success payload -> do
          decision <- contexts.contextForSpace payload.memorySpaceId
          case decision of
            Left denial ->
              pure
                ( FireFailedPermanently
                    ("L3 persona timer is not authorized for its memory space: " <> Text.pack (show denial))
                )
            Right context -> do
              result <- regeneratePersona rt (memoryContextSpace context) payload.scope
              pure $
                case result of
                  Right _ -> FireCompleted (timerMarkerEventId row.timerId)
                  Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))

getPersonaByScope ::
  (Store :> es) =>
  MemorySpaceId ->
  MemoryScope ->
  Eff es (Maybe PersonaRow)
getPersonaByScope memorySpaceId scope =
  runTransaction $
    Tx.statement (scopeKey memorySpaceId scope) selectPersonaByScopeStmt

getPersonaScenesByScope ::
  (Store :> es) =>
  MemorySpaceId ->
  MemoryScope ->
  Eff es [PersonaSceneRow]
getPersonaScenesByScope memorySpaceId scope =
  runTransaction $
    Tx.statement (scopeKey memorySpaceId scope) selectScenesForPersonaStmt

-- | A scope lookup inside one memory space, as a record rather than a four-tuple so that the
-- partition cannot be transposed with the namespace it sits beside.
data PartitionedScope = PartitionedScope
  { memorySpaceId :: !MemorySpaceId,
    namespace :: !Text,
    scopeKind :: !(Maybe Text),
    scopeRef :: !(Maybe Text)
  }

data PersonaKey = PersonaKey !MemorySpaceId !Text

scopeKey :: MemorySpaceId -> MemoryScope -> PartitionedScope
scopeKey memorySpaceId scope =
  PartitionedScope
    { memorySpaceId,
      namespace = scopeNamespaceText scope,
      scopeKind = scopeKindText scope,
      scopeRef = scopeRefText scope
    }

partitionedScopeEncoder :: E.Params PartitionedScope
partitionedScopeEncoder =
  ((\q -> q.memorySpaceId) >$< memorySpaceParam)
    <> ((\q -> q.namespace) >$< E.param (E.nonNullable E.text))
    <> ((\q -> q.scopeKind) >$< E.param (E.nullable E.text))
    <> ((\q -> q.scopeRef) >$< E.param (E.nullable E.text))

mirrorPersonaToCurrentWorkspace :: PersonaRow -> IO FilePath
mirrorPersonaToCurrentWorkspace row = do
  workspace <- getCurrentDirectory
  mirrorPersonaToWorkspace workspace row

mirrorPersonaToWorkspace :: FilePath -> PersonaRow -> IO FilePath
mirrorPersonaToWorkspace workspace row = do
  let path = personaMirrorPath workspace row
  createDirectoryIfMissing True (personaArtifactDir workspace row.memorySpaceId)
  TextIO.writeFile path (row.bodyMd <> "\n")
  pure path

-- | The mirror path is derived from the row's own memory space as well as its scope, for the
-- reason given at 'Kioku.Distill.L2.sceneMirrorPath': the slug cannot separate two spaces
-- because both are allowed to hold the same scope.
personaMirrorPath :: FilePath -> PersonaRow -> FilePath
personaMirrorPath workspace row =
  personaArtifactDir workspace row.memorySpaceId </> Text.unpack (personaScopeSlug row <> ".md")

-- | Where this persona's mirror lived before memory spaces existed. Only the legacy space can
-- have one; see 'Kioku.Distill.L2.sceneMirrorPath'.
legacyPersonaMirrorPath :: FilePath -> PersonaRow -> Maybe FilePath
legacyPersonaMirrorPath workspace row
  | row.memorySpaceId == legacyMemorySpaceId =
      Just (legacyPersonaArtifactDir workspace </> Text.unpack (personaScopeSlug row <> ".md"))
  | otherwise = Nothing

bestEffortMirrorPersona :: DistillRuntime -> PersonaRow -> IO ()
bestEffortMirrorPersona rt row = do
  let write = do
        workspace <- distillWorkspaceRoot rt
        mirrorPersonaToWorkspace workspace row
  _ <- try write :: IO (Either IOException FilePath)
  pure ()

-- | Remove a persona's mirror file. Best-effort for the same reason writing it
-- is: the database row is the durable artifact and is already deleted.
--
-- The legacy space's pre-partition mirror goes too, symmetrically with the scene delete in
-- "Kioku.Distill.L2": every scene this persona was distilled from is gone, so a surviving
-- @.kioku\/persona\/…@ file describes memories nobody may read any more.
bestEffortRemovePersonaMirror :: DistillRuntime -> PersonaRow -> IO ()
bestEffortRemovePersonaMirror rt row = do
  let remove = do
        workspace <- distillWorkspaceRoot rt
        traverse_
          removeIfPresent
          (personaMirrorPath workspace row : maybeToList (legacyPersonaMirrorPath workspace row))
  _ <- try remove :: IO (Either IOException ())
  pure ()

removeIfPresent :: FilePath -> IO ()
removeIfPresent path = do
  exists <- doesFileExist path
  when exists (removeFile path)

personaScopeSlug :: PersonaRow -> Text
personaScopeSlug row =
  scopeSlugFromColumns row.namespace row.scopeKind row.scopeRef

-- | The persistent primary key of a persona row. Escaped, so two distinct scopes can never
-- derive the same id.
personaRowId :: MemoryScope -> Text
personaRowId scope =
  "kioku_persona:" <> scopeIdentity scope

personaSourceHash :: [PersonaSceneRow] -> Text
personaSourceHash scenes =
  "v1:" <> Text.pack (show (Hash.hash (BL.toStrict (Aeson.encode (sceneSource <$> scenes))) :: Digest SHA256))

sceneSource :: PersonaSceneRow -> (Text, Text, UTCTime)
sceneSource scene =
  (scene.sceneId, scene.bodyMd, scene.updatedAt)

renderScenes :: [PersonaSceneRow] -> Text
renderScenes =
  Text.intercalate "\n\n" . fmap renderScene

renderScene :: PersonaSceneRow -> Text
renderScene scene =
  "# " <> scene.title <> "\n\n" <> scene.bodyMd

-- | A human-readable scope label for the LLM prompt. Deliberately *not* escaped and
-- deliberately not used for identity: a collision here is cosmetic. Identity comes from
-- 'scopeIdentity'.
renderScope :: MemoryScope -> Text
renderScope scope =
  Text.intercalate "/" $
    scopeNamespaceText scope : catMaybes [scopeKindText scope, scopeRefText scope]

l3PersonaTimerNamespace :: UUID
l3PersonaTimerNamespace =
  fromMaybe UUID.nil $
    UUID.fromString "6b696f6b-752d-7133-8000-706572736f6e"

personaRowDecoder :: D.Row PersonaRow
personaRowDecoder =
  PersonaRow
    <$> memorySpaceColumn
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> (fromIntegral @Int32 @Int <$> D.column (D.nonNullable D.int4))
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.timestamptz)
    <*> D.column (D.nonNullable D.timestamptz)

personaSceneRowDecoder :: D.Row PersonaSceneRow
personaSceneRowDecoder =
  PersonaSceneRow
    <$> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.timestamptz)

personaRowEncoder :: E.Params PersonaRow
personaRowEncoder =
  ((\row -> row.memorySpaceId) >$< memorySpaceParam)
    <> ((\row -> row.personaId) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.namespace) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.scopeKind) >$< E.param (E.nullable E.text))
    <> ((\row -> row.scopeRef) >$< E.param (E.nullable E.text))
    <> ((\row -> row.bodyMd) >$< E.param (E.nonNullable E.text))
    <> ((fromIntegral @Int @Int32 . \row -> row.sceneCount) >$< E.param (E.nonNullable E.int4))
    <> ((\row -> row.sourceHash) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.createdAt) >$< E.param (E.nonNullable E.timestamptz))
    <> ((\row -> row.updatedAt) >$< E.param (E.nonNullable E.timestamptz))

selectPersonaByScopeStmt :: Statement PartitionedScope (Maybe PersonaRow)
selectPersonaByScopeStmt =
  preparable
    ( """
      SELECT memory_space_id, persona_id, namespace, scope_kind, scope_ref, body_md, scene_count,
             source_hash, created_at, updated_at
      FROM
      """
        <> " "
        <> personasTable
        <> " "
        <> """
           WHERE memory_space_id = $1
             AND namespace = $2
             AND ((scope_kind = $3 AND scope_ref = $4)
                  OR ($3 IS NULL AND scope_kind IS NULL AND $4 IS NULL AND scope_ref IS NULL))
           """
    )
    partitionedScopeEncoder
    (D.rowMaybe personaRowDecoder)

selectScenesForPersonaStmt :: Statement PartitionedScope [PersonaSceneRow]
selectScenesForPersonaStmt =
  preparable
    ( """
      SELECT scene_id, title, body_md, updated_at
      FROM
      """
        <> " "
        <> scenesTable
        <> " "
        <> """
           WHERE memory_space_id = $1
             AND namespace = $2
             AND ((scope_kind = $3 AND scope_ref = $4)
                  OR ($3 IS NULL AND scope_kind IS NULL AND $4 IS NULL AND scope_ref IS NULL))
           ORDER BY scene_key ASC, updated_at DESC
           """
    )
    partitionedScopeEncoder
    (D.rowList personaSceneRowDecoder)

-- | Delete by the row's own primary key, for the same reason 'deleteSceneStmt' does: the
-- scope-identity string format is not re-implemented here. That key is now composite, because
-- the persona id alone is derived from the scope and two spaces may share one.
deletePersonaStmt :: Statement PersonaKey ()
deletePersonaStmt =
  preparable
    ("DELETE FROM " <> personasTable <> " WHERE memory_space_id = $1 AND persona_id = $2")
    ( ((\(PersonaKey space _) -> space) >$< memorySpaceParam)
        <> ((\(PersonaKey _ personaId) -> personaId) >$< E.param (E.nonNullable E.text))
    )
    D.noResult

upsertPersonaStmt :: Statement PersonaRow ()
upsertPersonaStmt =
  preparable
    ( "INSERT INTO "
        <> personasTable
        <> "\n"
        <> """
             (memory_space_id, persona_id, namespace, scope_kind, scope_ref, body_md, scene_count,
              source_hash, created_at, updated_at)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
           ON CONFLICT (memory_space_id, persona_id) DO UPDATE SET
             body_md = EXCLUDED.body_md,
             scene_count = EXCLUDED.scene_count,
             source_hash = EXCLUDED.source_hash,
             updated_at = EXCLUDED.updated_at
           """
    )
    personaRowEncoder
    D.noResult