never executed always true always false
1 module PureClaw.Transcript.Types
2 ( -- * Direction
3 Direction (..)
4 -- * Transcript entry
5 , TranscriptEntry (..)
6 -- * Filter
7 , TranscriptFilter (..)
8 , emptyFilter
9 , matchesFilter
10 , applyFilter
11 -- * Payload helpers
12 , encodePayload
13 , decodePayload
14 ) where
15
16 import Data.Aeson (FromJSON, ToJSON, Value)
17 import Data.ByteString (ByteString)
18 import Data.Map.Strict (Map)
19 import Data.Text (Text)
20 import Data.Text.Encoding qualified as TE
21 import Data.Time (UTCTime)
22 import GHC.Generics (Generic)
23
24 -- | Direction of an API call.
25 data Direction = Request | Response
26 deriving stock (Show, Eq, Generic)
27
28 instance ToJSON Direction
29 instance FromJSON Direction
30
31 -- | A single transcript entry recording one request or response.
32 data TranscriptEntry = TranscriptEntry
33 { _te_id :: !Text -- ^ UUID
34 , _te_timestamp :: !UTCTime
35 , _te_harness :: !(Maybe Text) -- ^ e.g. Just "claude-code", Nothing if direct
36 , _te_model :: !(Maybe Text) -- ^ e.g. Just "llama3", Nothing if unknown
37 , _te_direction :: !Direction
38 , _te_payload :: !Text -- ^ Raw text payload
39 , _te_durationMs :: !(Maybe Int) -- ^ present on Response entries only
40 , _te_correlationId :: !Text -- ^ shared UUID linking Request to its Response
41 , _te_metadata :: !(Map Text Value) -- ^ extensible
42 }
43 deriving stock (Show, Eq, Generic)
44
45 instance ToJSON TranscriptEntry
46 instance FromJSON TranscriptEntry
47
48 -- | Record-of-Maybe filter; all fields AND together.
49 data TranscriptFilter = TranscriptFilter
50 { _tf_harness :: !(Maybe Text)
51 , _tf_model :: !(Maybe Text)
52 , _tf_direction :: !(Maybe Direction)
53 , _tf_timeRange :: !(Maybe (UTCTime, UTCTime))
54 , _tf_limit :: !(Maybe Int)
55 }
56 deriving stock (Show, Eq)
57
58 -- | A filter that matches all entries with no limit.
59 emptyFilter :: TranscriptFilter
60 emptyFilter = TranscriptFilter
61 { _tf_harness = Nothing
62 , _tf_model = Nothing
63 , _tf_direction = Nothing
64 , _tf_timeRange = Nothing
65 , _tf_limit = Nothing
66 }
67
68 -- | Pure predicate: does an entry match the non-limit filter criteria?
69 -- '_tf_limit' is intentionally NOT checked here — it is applied by 'applyFilter'.
70 matchesFilter :: TranscriptFilter -> TranscriptEntry -> Bool
71 matchesFilter tf entry =
72 maybe True (\h -> _te_harness entry == Just h) (_tf_harness tf)
73 && maybe True (\m -> _te_model entry == Just m) (_tf_model tf)
74 && maybe True (\d -> _te_direction entry == d) (_tf_direction tf)
75 && maybe True (\(lo, hi) ->
76 let ts = _te_timestamp entry in ts >= lo && ts <= hi) (_tf_timeRange tf)
77
78 -- | Apply the filter to a list of entries.
79 -- First filters by 'matchesFilter', then applies '_tf_limit' (take N).
80 applyFilter :: TranscriptFilter -> [TranscriptEntry] -> [TranscriptEntry]
81 applyFilter tf = applyLimit . filter (matchesFilter tf)
82 where
83 applyLimit = maybe id take (_tf_limit tf)
84
85 -- | Encode raw bytes to text for storage in '_te_payload'.
86 -- Uses UTF-8 decoding (lenient — replaces invalid bytes with U+FFFD).
87 encodePayload :: ByteString -> Text
88 encodePayload = TE.decodeUtf8Lenient
89
90 -- | Encode text payload back to raw bytes.
91 decodePayload :: Text -> Maybe ByteString
92 decodePayload = Just . TE.encodeUtf8