packages feed

kioku-core-0.1.0.0: src/Kioku/Distill/L1.hs

{-# LANGUAGE DataKinds #-}

module Kioku.Distill.L1
  ( FindMergeCandidates (..),
    L1Error (..),
    L1Outcome (..),
    L1RunMode (..),
    L1Summary (..),
    distillSessionL1,
    recallCandidates,
    scopedScanCandidates,
  )
where

import Baikai.Embedding (EmbeddingModel)
import Control.Monad (foldM)
import Data.Aeson qualified as Aeson
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Either (lefts)
import Data.Functor.Contravariant ((>$<))
import Data.Int (Int32)
import Data.KindID.V7 qualified as KindID
import Data.List (nub)
import Data.Maybe (catMaybes)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Data.UUID.V5 qualified as UUIDv5
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
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.ReadModel (ReadModelError)
import Kioku.Api.Scope (MemoryScope, scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText)
import Kioku.Api.Types (Confidence (..), MemoryRecord (..), MemoryType (..), confidenceFromText, memoryTypeFromText)
import Kioku.Distill.Consolidate
  ( ConsolidateInput (..),
    ConsolidationAction (..),
    ConsolidationDecision (..),
    ExistingMemory (..),
  )
import Kioku.Distill.Extract (ExtractInput (..), ExtractOutput (..), ExtractedAtom (..))
import Kioku.Distill.Runtime (DistillRuntime, runConsolidation, runExtraction)
import Kioku.Id (MemoryId, SessionId, idText, parseIdLenient)
import Kioku.Memory qualified as Memory
import Kioku.Memory.Domain (RecordMemoryData (..))
import Kioku.Memory.ReadModel (MemoryRow (..))
import Kioku.Prelude
import Kioku.Recall qualified as Recall
import Kioku.Recall.Capability (VectorCapability)
import Kioku.Session qualified as Session
import Kioku.Session.ReadModel (SessionRow (..), TurnRow (..))
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import Shikumi.Schema.Types (field, unField)

newtype FindMergeCandidates es = FindMergeCandidates
  { runFindMergeCandidates :: MemoryScope -> Text -> Eff es (Either ReadModelError [MemoryRecord])
  }

data L1Error
  = L1SessionReadFailed !ReadModelError
  | L1SessionNotFound !SessionId
  | L1TurnReadFailed !ReadModelError
  | L1MemoryReadFailed !ReadModelError
  | L1ExtractionFailed !Text
  | L1ConsolidationFailed !Text
  | L1MemoryWriteFailed !Memory.MemoryWriteError
  deriving stock (Generic, Show)

data L1Summary = L1Summary
  { extracted :: !Int,
    stored :: !Int,
    merged :: !Int,
    skipped :: !Int
  }
  deriving stock (Generic, Eq, Show)

-- | Whether a pass may skip itself when the per-session watermark shows no new
-- turns since the last fully successful pass. Timer fires use
-- 'RespectWatermark' (the debounce); @kioku distill --force@ uses
-- 'IgnoreWatermark'.
data L1RunMode
  = RespectWatermark
  | IgnoreWatermark
  deriving stock (Generic, Eq, Show)

data L1Outcome
  = L1Distilled !L1Summary
  | L1SkippedUpToDate
  deriving stock (Generic, Eq, Show)

-- | What the pass actually did with an atom, as opposed to what the
-- consolidator asked for. A merge whose targets all turned out to be missing
-- degrades to a store; a merge whose only target is the atom's own prior copy
-- degrades to a skip. The audit row records this, not the LLM's claim.
data AppliedAction
  = ActionStored
  | ActionUpdated
  | ActionMerged
  | ActionSkipped
  deriving stock (Generic, Eq, Show)

data AppliedDecision = AppliedDecision
  { appliedAction :: !AppliedAction,
    winnerId :: !(Maybe MemoryId),
    appliedTargets :: ![MemoryId],
    appliedNote :: !(Maybe Text)
  }

data WatermarkRow = WatermarkRow
  { sessionId :: !Text,
    lastTurnIndex :: !Int32,
    distilledAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

data AuditRow = AuditRow
  { decisionId :: !Text,
    sessionId :: !Text,
    namespace :: !Text,
    scopeKind :: !(Maybe Text),
    scopeRef :: !(Maybe Text),
    candidateContent :: !Text,
    decision :: !Text,
    targetIds :: ![Text],
    resultMemoryId :: !(Maybe Text),
    rationale :: !(Maybe Text),
    decidedAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)

-- | Run one L1 distillation pass. Under 'RespectWatermark' a session whose
-- highest turn index is already covered by a previous fully successful pass is
-- skipped before any LLM call, which is what makes keiro's at-least-once timer
-- re-fires cheap. The watermark advances only when the whole fold succeeds, so
-- a failed pass is retried in full.
distillSessionL1 ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  L1RunMode ->
  DistillRuntime ->
  FindMergeCandidates es ->
  SessionId ->
  Eff es (Either L1Error L1Outcome)
distillSessionL1 mode rt finder sid = do
  sessionResult <- Session.getById sid
  case sessionResult of
    Left err -> pure (Left (L1SessionReadFailed err))
    Right Nothing -> pure (Left (L1SessionNotFound sid))
    Right (Just session) -> do
      turnsResult <- Session.getTurns sid
      case turnsResult of
        Left err -> pure (Left (L1TurnReadFailed err))
        Right turns -> do
          let maxTurnIndex = maximum (0 : fmap (.turnIndex) turns)
          upToDate <- watermarkCovers mode sid maxTurnIndex
          if upToDate
            then pure (Right L1SkippedUpToDate)
            else do
              inputResult <- buildExtractInput sid session turns
              case inputResult of
                Left err -> pure (Left err)
                Right input -> do
                  extractedResult <- liftIO (runExtraction rt input)
                  case extractedResult of
                    Left err -> pure (Left (L1ExtractionFailed (Text.pack (show err))))
                    Right output -> do
                      foldResult <-
                        foldM
                          (stepAtom maxTurnIndex session)
                          (Right emptySummary {extracted = length output.atoms})
                          output.atoms
                      case foldResult of
                        Left err -> pure (Left err)
                        Right summary -> do
                          writeWatermark sid maxTurnIndex
                          pure (Right (L1Distilled summary))
  where
    stepAtom _ _ (Left err) _ = pure (Left err)
    stepAtom maxTurnIndex session (Right summary) atom =
      applyAtom rt finder sid session maxTurnIndex summary atom

watermarkCovers ::
  (Store :> es) =>
  L1RunMode ->
  SessionId ->
  Int ->
  Eff es Bool
watermarkCovers IgnoreWatermark _ _ = pure False
watermarkCovers RespectWatermark sid maxTurnIndex = do
  stored <- readWatermark sid
  pure (maybe False (>= maxTurnIndex) stored)

readWatermark ::
  (Store :> es) =>
  SessionId ->
  Eff es (Maybe Int)
readWatermark sid =
  runTransaction $
    fmap fromIntegral <$> Tx.statement (idText sid) selectWatermarkStmt

writeWatermark ::
  (IOE :> es, Store :> es) =>
  SessionId ->
  Int ->
  Eff es ()
writeWatermark sid maxTurnIndex = do
  now <- liftIO getCurrentTime
  runTransaction $
    Tx.statement
      WatermarkRow
        { sessionId = idText sid,
          lastTurnIndex = fromIntegral maxTurnIndex,
          distilledAt = now
        }
      upsertWatermarkStmt

scopedScanCandidates ::
  (IOE :> es, Store :> es) =>
  Int ->
  FindMergeCandidates es
scopedScanCandidates limit =
  FindMergeCandidates \scope _query ->
    fmap (take (max 0 limit)) <$> Recall.getActiveByScope scope

recallCandidates ::
  (IOE :> es, Store :> es) =>
  EmbeddingModel ->
  VectorCapability ->
  Int ->
  FindMergeCandidates es
recallCandidates model capability limit =
  FindMergeCandidates \scope query -> do
    hits <-
      Recall.recall
        model
        capability
        Recall.RecallRequest
          { scope,
            query,
            strategy = Recall.Hybrid,
            maxResults = max 0 limit
          }
    pure (Right (fmap (.memory) hits))

buildExtractInput ::
  (IOE :> es, Store :> es) =>
  SessionId ->
  SessionRow ->
  [TurnRow] ->
  Eff es (Either L1Error ExtractInput)
buildExtractInput sid session turns = do
  memoryTextResult <-
    if null turns
      then fallbackMemoryText sid (sessionScope session)
      else pure (Right (renderTurns turns))
  pure do
    memoryText <- memoryTextResult
    Right
      ExtractInput
        { focus = field session.focus,
          scopeLabel = field (renderScope (sessionScope session)),
          conversation = field memoryText
        }

fallbackMemoryText ::
  (IOE :> es, Store :> es) =>
  SessionId ->
  MemoryScope ->
  Eff es (Either L1Error Text)
fallbackMemoryText sid scope = do
  bySession <- Recall.getBySession sid
  case bySession of
    Left err -> pure (Left (L1MemoryReadFailed err))
    Right rows
      | not (null rows) -> pure (Right (renderMemories rows))
      | otherwise -> do
          byScope <- Recall.getActiveByScope scope
          pure $
            case byScope of
              Left err -> Left (L1MemoryReadFailed err)
              Right scopeRows -> Right (renderMemories scopeRows)

applyAtom ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  DistillRuntime ->
  FindMergeCandidates es ->
  SessionId ->
  SessionRow ->
  Int ->
  L1Summary ->
  ExtractedAtom ->
  Eff es (Either L1Error L1Summary)
applyAtom rt finder sid session maxTurnIndex summary atom = do
  candidatesResult <- finder.runFindMergeCandidates (sessionScope session) (unField atom.content)
  case candidatesResult of
    Left err -> pure (Left (L1MemoryReadFailed err))
    Right candidates -> do
      decisionResult <-
        liftIO $
          runConsolidation
            rt
            ConsolidateInput
              { scopeLabel = field (renderScope (sessionScope session)),
                candidate = atom,
                existing = existingMemory <$> candidates
              }
      case decisionResult of
        Left err -> pure (Left (L1ConsolidationFailed (Text.pack (show err))))
        Right decision -> do
          appliedResult <- applyDecision sid session atom decision
          case appliedResult of
            Left err -> pure (Left err)
            Right applied -> do
              writeAudit sid session atom maxTurnIndex decision applied
              pure (Right (addAppliedDecision summary applied))

-- | Apply a consolidation decision, writing nothing until the whole plan for
-- the atom is known to be executable. The winner id is a deterministic function
-- of the session and the atom content, so every write here is idempotent under
-- keiro's at-least-once timer contract.
applyDecision ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  SessionId ->
  SessionRow ->
  ExtractedAtom ->
  ConsolidationDecision ->
  Eff es (Either L1Error AppliedDecision)
applyDecision sid session atom decision =
  case decision.action of
    SkipAtom -> pure (Right (appliedSkip Nothing))
    StoreAtom -> storeWinner Nothing Nothing
    UpdateAtom -> mergeInto ActionUpdated
    MergeAtom -> mergeInto ActionMerged
  where
    winner = l1AtomMemoryId sid (unField atom.content)

    appliedSkip note =
      AppliedDecision
        { appliedAction = ActionSkipped,
          winnerId = Nothing,
          appliedTargets = [],
          appliedNote = note
        }

    storeWinner note supersedes =
      fmap
        ( \mid ->
            AppliedDecision
              { appliedAction = ActionStored,
                winnerId = Just mid,
                appliedTargets = [],
                appliedNote = note
              }
        )
        <$> recordAtom sid session atom decision winner supersedes

    mergeInto action = do
      let requested = nub (parsedTargetIds decision)
          nonSelf = filter (/= winner) requested
          degradeNote
            | null requested = "no usable merge targets supplied; stored the candidate"
            | otherwise = "targets missing; degraded to store"
      if not (null requested) && null nonSelf
        then pure (Right (appliedSkip (Just selfTargetNote)))
        else do
          winnerRow <- Memory.getMemoryRowById winner
          case winnerRow of
            Left err -> pure (Left (L1MemoryReadFailed err))
            Right (Just row)
              | row.status /= "active" ->
                  pure (Right (appliedSkip (Just (retiredWinnerNote row.status))))
            _ -> do
              resolved <- resolveExistingTargets nonSelf
              case resolved of
                Left err -> pure (Left err)
                Right [] -> storeWinner (Just degradeNote) Nothing
                Right targets@(firstTarget : _) -> do
                  winnerResult <- recordAtom sid session atom decision winner (Just firstTarget)
                  case winnerResult of
                    Left err -> pure (Left err)
                    Right stored -> do
                      mergeResults <- traverse (\target -> requireMemoryWrite =<< Memory.merge target stored) targets
                      pure $
                        case lefts mergeResults of
                          err : _ -> Left err
                          [] ->
                            Right
                              AppliedDecision
                                { appliedAction = action,
                                  winnerId = Just stored,
                                  appliedTargets = targets,
                                  appliedNote = Nothing
                                }

-- | Drop target ids that name no row in the read model. A hallucinated but
-- syntactically valid TypeID would otherwise fail @Memory.merge@ with
-- 'Memory.MemoryNotFound' /after/ the winner had already been recorded,
-- wedging the timer and leaking one memory per retry.
resolveExistingTargets ::
  (IOE :> es, Store :> es) =>
  [MemoryId] ->
  Eff es (Either L1Error [MemoryId])
resolveExistingTargets =
  foldM step (Right [])
  where
    step (Left err) _ = pure (Left err)
    step (Right acc) mid = do
      row <- Memory.getMemoryRowById mid
      pure $
        case row of
          Left err -> Left (L1MemoryReadFailed err)
          Right Nothing -> Right acc
          Right (Just _) -> Right (acc <> [mid])

selfTargetNote :: Text
selfTargetNote =
  "merge target is the atom's own prior copy; already represented"

retiredWinnerNote :: Text -> Text
retiredWinnerNote status =
  "this atom was already distilled and is now " <> status <> "; already represented"

recordAtom ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  SessionId ->
  SessionRow ->
  ExtractedAtom ->
  ConsolidationDecision ->
  MemoryId ->
  Maybe MemoryId ->
  Eff es (Either L1Error MemoryId)
recordAtom sid session atom decision memoryId supersedes = do
  now <- liftIO getCurrentTime
  requireMemoryWrite
    =<< Memory.record
      RecordMemoryData
        { memoryId,
          agentId = session.agentId,
          sessionId = Just sid,
          scope = sessionScope session,
          memoryType = atomMemoryType atom,
          content = resolvedContent atom decision,
          priority = unField atom.priority,
          confidence = atomConfidence atom,
          tags = Set.fromList ["distilled", "l1"],
          supersedes,
          recordedAt = now
        }

requireMemoryWrite :: (Applicative f) => Either Memory.MemoryWriteError MemoryId -> f (Either L1Error MemoryId)
requireMemoryWrite = \case
  Left err -> pure (Left (L1MemoryWriteFailed err))
  Right mid -> pure (Right mid)

-- | The audit key is a deterministic function of the session, the pass's
-- maximum turn index, and the atom content, so @ON CONFLICT (decision_id) DO
-- NOTHING@ collapses re-fires of the same pass into one row while a later pass
-- over new turns still writes its own.
writeAudit ::
  (IOE :> es, Store :> es) =>
  SessionId ->
  SessionRow ->
  ExtractedAtom ->
  Int ->
  ConsolidationDecision ->
  AppliedDecision ->
  Eff es ()
writeAudit sid session atom maxTurnIndex decision applied = do
  now <- liftIO getCurrentTime
  runTransaction $
    Tx.statement
      AuditRow
        { decisionId = l1AuditKey sid maxTurnIndex (unField atom.content),
          sessionId = idText sid,
          namespace = scopeNamespaceText (sessionScope session),
          scopeKind = scopeKindText (sessionScope session),
          scopeRef = scopeRefText (sessionScope session),
          candidateContent = unField atom.content,
          decision = appliedActionText applied.appliedAction,
          targetIds = idText <$> applied.appliedTargets,
          resultMemoryId = idText <$> applied.winnerId,
          rationale = Just (auditRationale decision applied),
          decidedAt = now
        }
      insertAuditStmt

auditRationale :: ConsolidationDecision -> AppliedDecision -> Text
auditRationale decision applied =
  unField decision.rationale <> maybe "" (\note -> " [" <> note <> "]") applied.appliedNote

-- | UUIDv5 namespace for every deterministic L1 identity (atom memory ids and
-- consolidation audit keys).
l1AtomNamespace :: UUID
l1AtomNamespace =
  fromMaybe UUID.nil $
    UUID.fromString "6b696f6b-752d-6c31-8000-61746f6d6964"

l1Uuid5 :: Text -> UUID
l1Uuid5 =
  UUIDv5.generateNamed l1AtomNamespace . BS.unpack . TE.encodeUtf8

-- | The memory id an extracted atom always maps to. Keyed on the /candidate/
-- content rather than the consolidator's rewritten @resultContent@, so the id
-- stays stable across retries whose rewrites differ.
l1AtomMemoryId :: SessionId -> Text -> MemoryId
l1AtomMemoryId sid content =
  KindID.decorateKindID (l1Uuid5 (idText sid <> ":" <> content))

l1AuditKey :: SessionId -> Int -> Text -> Text
l1AuditKey sid maxTurnIndex content =
  "kioku_consolidation_decision:"
    <> UUID.toText
      (l1Uuid5 ("audit:" <> idText sid <> ":" <> Text.pack (show maxTurnIndex) <> ":" <> content))

existingMemory :: MemoryRecord -> ExistingMemory
existingMemory row =
  ExistingMemory
    { memoryId = field row.memoryId,
      memoryType = field row.memoryType,
      content = field row.content,
      priority = field row.priority,
      confidence = field row.confidence
    }

parsedTargetIds :: ConsolidationDecision -> [MemoryId]
parsedTargetIds decision =
  mapMaybe parseTargetId decision.targetMemoryIds

parseTargetId :: Text -> Maybe MemoryId
parseTargetId =
  either (const Nothing) Just . parseIdLenient

resolvedContent :: ExtractedAtom -> ConsolidationDecision -> Text
resolvedContent atom decision =
  fromMaybe (unField atom.content) decision.resultContent

atomMemoryType :: ExtractedAtom -> MemoryType
atomMemoryType atom =
  fromMaybe MemoryFact $
    memoryTypeFromText (Text.toLower (unField atom.atomType))

atomConfidence :: ExtractedAtom -> Confidence
atomConfidence atom =
  fromMaybe MediumConfidence $
    confidenceFromText (Text.toLower (unField atom.confidence))

sessionScope :: SessionRow -> MemoryScope
sessionScope session =
  scopeFromColumns session.namespace session.scopeKind session.scopeRef

renderScope :: MemoryScope -> Text
renderScope scope =
  Text.intercalate "/" $
    scopeNamespaceText scope : catMaybes [scopeKindText scope, scopeRefText scope]

renderTurns :: [TurnRow] -> Text
renderTurns =
  Text.intercalate "\n" . fmap renderTurn

renderTurn :: TurnRow -> Text
renderTurn turn =
  Text.pack (show turn.turnIndex)
    <> ". "
    <> turn.role
    <> ": "
    <> turn.content
    <> maybe "" ("\n   tools: " <>) turn.toolSummary

renderMemories :: [MemoryRecord] -> Text
renderMemories =
  Text.intercalate "\n" . fmap renderMemory

renderMemory :: MemoryRecord -> Text
renderMemory row =
  "- " <> row.memoryType <> ": " <> row.content

addAppliedDecision :: L1Summary -> AppliedDecision -> L1Summary
addAppliedDecision summary applied =
  case applied.appliedAction of
    ActionStored -> summary {stored = summary.stored + 1}
    ActionUpdated -> summary {merged = summary.merged + 1}
    ActionMerged -> summary {merged = summary.merged + 1}
    ActionSkipped -> summary {skipped = summary.skipped + 1}

appliedActionText :: AppliedAction -> Text
appliedActionText = \case
  ActionStored -> "store"
  ActionUpdated -> "update"
  ActionMerged -> "merge"
  ActionSkipped -> "skip"

emptySummary :: L1Summary
emptySummary = L1Summary {extracted = 0, stored = 0, merged = 0, skipped = 0}

encodeTargetIds :: [Text] -> Text
encodeTargetIds =
  TE.decodeUtf8 . BL.toStrict . Aeson.encode

selectWatermarkStmt :: Statement Text (Maybe Int32)
selectWatermarkStmt =
  preparable
    """
    SELECT last_turn_index
    FROM kioku_l1_watermarks
    WHERE session_id = $1
    """
    (E.param (E.nonNullable E.text))
    (D.rowMaybe (D.column (D.nonNullable D.int4)))

-- | @GREATEST@ keeps the watermark monotonic: a slow pass over turns 1-3 that
-- lands after a fast pass over turns 1-5 must not rewind it.
upsertWatermarkStmt :: Statement WatermarkRow ()
upsertWatermarkStmt =
  preparable
    """
    INSERT INTO kioku_l1_watermarks (session_id, last_turn_index, distilled_at)
    VALUES ($1, $2, $3)
    ON CONFLICT (session_id) DO UPDATE
      SET last_turn_index =
            GREATEST(kioku_l1_watermarks.last_turn_index, EXCLUDED.last_turn_index),
          distilled_at = EXCLUDED.distilled_at
    """
    watermarkRowEncoder
    D.noResult

watermarkRowEncoder :: E.Params WatermarkRow
watermarkRowEncoder =
  ((\row -> row.sessionId) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.lastTurnIndex) >$< E.param (E.nonNullable E.int4))
    <> ((\row -> row.distilledAt) >$< E.param (E.nonNullable E.timestamptz))

insertAuditStmt :: Statement AuditRow ()
insertAuditStmt =
  preparable
    """
    INSERT INTO kioku_consolidation_decisions
      (decision_id, session_id, namespace, scope_kind, scope_ref, candidate_content,
       decision, target_ids, result_memory_id, rationale, decided_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)
    ON CONFLICT (decision_id) DO NOTHING
    """
    auditRowEncoder
    D.noResult

auditRowEncoder :: E.Params AuditRow
auditRowEncoder =
  ((\row -> row.decisionId) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.sessionId) >$< 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.candidateContent) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.decision) >$< E.param (E.nonNullable E.text))
    <> ((encodeTargetIds . \row -> row.targetIds) >$< E.param (E.nonNullable E.text))
    <> ((\row -> row.resultMemoryId) >$< E.param (E.nullable E.text))
    <> ((\row -> row.rationale) >$< E.param (E.nullable E.text))
    <> ((\row -> row.decidedAt) >$< E.param (E.nonNullable E.timestamptz))