lucida-analytics 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lucida/__init__.py +72 -0
- lucida/_agent.py +257 -0
- lucida/_ai_config.py +65 -0
- lucida/_build.py +104 -0
- lucida/_context.py +158 -0
- lucida/_core.py +147 -0
- lucida/_eval.py +297 -0
- lucida/_flagcache.py +72 -0
- lucida/_flagcache_kv.py +45 -0
- lucida/_flags.py +156 -0
- lucida/_global.py +49 -0
- lucida/_ops_v2.py +137 -0
- lucida/_queue.py +304 -0
- lucida/_snapshot.py +74 -0
- lucida/_stream.py +26 -0
- lucida/_testmode.py +59 -0
- lucida/aio.py +517 -0
- lucida/asgi.py +60 -0
- lucida/client.py +539 -0
- lucida/flask_integration.py +62 -0
- lucida/openfeature_provider.py +244 -0
- lucida/py.typed +0 -0
- lucida_analytics-0.1.0.dist-info/METADATA +189 -0
- lucida_analytics-0.1.0.dist-info/RECORD +26 -0
- lucida_analytics-0.1.0.dist-info/WHEEL +4 -0
- lucida_analytics-0.1.0.dist-info/licenses/LICENSE +202 -0
lucida/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Lucida server SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from ._ai_config import AiConfig, ai_config
|
|
5
|
+
from ._agent import AgentConversation
|
|
6
|
+
from ._build import BeforeSend, OnError
|
|
7
|
+
from ._context import (
|
|
8
|
+
DISTINCT_ID_HEADER,
|
|
9
|
+
SESSION_ID_HEADER,
|
|
10
|
+
Context,
|
|
11
|
+
client_ip,
|
|
12
|
+
get_context,
|
|
13
|
+
identify_context,
|
|
14
|
+
new_context,
|
|
15
|
+
read_tracing_headers,
|
|
16
|
+
request_context_args,
|
|
17
|
+
request_properties,
|
|
18
|
+
scoped,
|
|
19
|
+
set_context_session,
|
|
20
|
+
tag,
|
|
21
|
+
)
|
|
22
|
+
from ._core import VERSION as __version__
|
|
23
|
+
from ._core import ImportEvent, LucidaError
|
|
24
|
+
from ._flagcache import FlagDefinitionCacheProvider, InMemoryFlagDefinitionCache
|
|
25
|
+
from ._flagcache_kv import RedisFlagDefinitionCache, RedisLike
|
|
26
|
+
from ._flags import FlagDetails, FlagValue
|
|
27
|
+
from ._snapshot import FlagSnapshot, device_scope, flag_event_props
|
|
28
|
+
from ._global import delegate as _delegate
|
|
29
|
+
from ._global import shutdown as shutdown
|
|
30
|
+
from .aio import AsyncLucida
|
|
31
|
+
from .client import Lucida
|
|
32
|
+
|
|
33
|
+
# Module-level facade: set these, then call lucida.capture(...) etc. (see README "Global client").
|
|
34
|
+
api_key: str | None = None
|
|
35
|
+
host: str | None = None
|
|
36
|
+
secret_key: str | None = None
|
|
37
|
+
debug: bool | None = None
|
|
38
|
+
disabled: bool | None = None
|
|
39
|
+
flush_at: int | None = None
|
|
40
|
+
flush_interval: float | None = None
|
|
41
|
+
max_batch_size: int | None = None
|
|
42
|
+
before_send: BeforeSend | None = None
|
|
43
|
+
on_error: OnError | None = None
|
|
44
|
+
sync_mode: bool | None = None
|
|
45
|
+
disable_geoip: bool | None = None
|
|
46
|
+
flag_timeout: float | None = None
|
|
47
|
+
default_properties: dict[str, object] | None = None
|
|
48
|
+
timeout: float | None = None
|
|
49
|
+
|
|
50
|
+
capture = _delegate("capture")
|
|
51
|
+
capture_immediate = _delegate("capture_immediate")
|
|
52
|
+
identify = _delegate("identify")
|
|
53
|
+
alias = _delegate("alias")
|
|
54
|
+
group_identify = _delegate("group_identify")
|
|
55
|
+
flush = _delegate("flush")
|
|
56
|
+
register = _delegate("register")
|
|
57
|
+
unregister = _delegate("unregister")
|
|
58
|
+
get_feature_flag = _delegate("get_feature_flag")
|
|
59
|
+
is_feature_enabled = _delegate("is_feature_enabled")
|
|
60
|
+
get_feature_flag_payload = _delegate("get_feature_flag_payload")
|
|
61
|
+
get_ai_config = _delegate("get_ai_config")
|
|
62
|
+
get_all_flags = _delegate("get_all_flags")
|
|
63
|
+
get_remote_config_payload = _delegate("get_remote_config_payload")
|
|
64
|
+
evaluate_flags = _delegate("evaluate_flags")
|
|
65
|
+
|
|
66
|
+
__all__: list[str] = [
|
|
67
|
+
"AgentConversation", "DISTINCT_ID_HEADER", "SESSION_ID_HEADER", "AsyncLucida", "Context", "FlagDefinitionCacheProvider",
|
|
68
|
+
"FlagDetails", "FlagValue", "ImportEvent", "InMemoryFlagDefinitionCache", "Lucida", "LucidaError", "RedisFlagDefinitionCache", "RedisLike", "__version__",
|
|
69
|
+
"client_ip", "get_context", "identify_context", "new_context", "read_tracing_headers", "request_context_args", "request_properties",
|
|
70
|
+
"scoped", "set_context_session", "tag", "shutdown", "FlagSnapshot", "device_scope", "flag_event_props",
|
|
71
|
+
"AiConfig", "ai_config",
|
|
72
|
+
] # fmt: skip
|
lucida/_agent.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Agent analytics (P-AGENT): conversation events for in-product AI agents and chatbots.
|
|
2
|
+
|
|
3
|
+
Product side only: no tokens, latency, cost or model names (Watchtower owns LLM observability). The only
|
|
4
|
+
Watchtower touchpoint is the optional `$ai_trace_id`. Helpers match packages/sdk-core/src/agent.ts and the
|
|
5
|
+
vectors in testdata/agent-analytics/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
Props = dict[str, Any]
|
|
16
|
+
Emit = Callable[[str, Props], None]
|
|
17
|
+
|
|
18
|
+
ROLES = ("user", "assistant")
|
|
19
|
+
OUTCOMES = ("resolved", "unresolved", "escalated", "abandoned", "error")
|
|
20
|
+
FEEDBACK_KINDS = ("thumbs", "rating", "comment")
|
|
21
|
+
MAX_TEXT, MAX_COMMENT = 4000, 2000
|
|
22
|
+
|
|
23
|
+
_TOKEN = re.compile(r"[^\W_]+")
|
|
24
|
+
_TRACE = re.compile(r"[0-9a-f]{32}")
|
|
25
|
+
_PUNCT = re.compile(r"[.,!?;:]")
|
|
26
|
+
_WS = re.compile(r"\s+")
|
|
27
|
+
_FNV_OFFSET, _FNV_PRIME, _MASK = 0xCBF29CE484222325, 0x100000001B3, (1 << 64) - 1
|
|
28
|
+
|
|
29
|
+
NEG = (
|
|
30
|
+
"wrong", "useless", "not helpful", "unhelpful", "doesn't work", "does not work", "didn't help",
|
|
31
|
+
"did not help", "not what i asked", "that's not it", "stupid", "terrible", "awful", "frustrating",
|
|
32
|
+
"frustrated", "annoying", "worst", "hate", "incorrect", "nonsense", "confusing", "makes no sense",
|
|
33
|
+
"real person", "human agent", "talk to a human", "speak to a human", "wtf", "ugh",
|
|
34
|
+
) # fmt: skip
|
|
35
|
+
POS = ("thanks", "thank you", "great", "perfect", "awesome", "helpful", "that worked", "works now", "solved", "exactly")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fnv1a64(token: str) -> int:
|
|
39
|
+
h = _FNV_OFFSET
|
|
40
|
+
for b in token.encode("utf-8"):
|
|
41
|
+
h = ((h ^ b) * _FNV_PRIME) & _MASK
|
|
42
|
+
return h
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def text_hash(text: str) -> str | None:
|
|
46
|
+
"""simhash64 of the lowercased letter/digit tokens (>= 2 code points), 16 hex chars; None without tokens."""
|
|
47
|
+
tokens = [t for t in _TOKEN.findall(text.lower()) if len(t) >= 2]
|
|
48
|
+
if not tokens:
|
|
49
|
+
return None
|
|
50
|
+
v = [0] * 64
|
|
51
|
+
for t in tokens:
|
|
52
|
+
h = _fnv1a64(t)
|
|
53
|
+
for i in range(64):
|
|
54
|
+
v[i] += 1 if (h >> i) & 1 else -1
|
|
55
|
+
out = sum(1 << i for i in range(64) if v[i] > 0)
|
|
56
|
+
return f"{out:016x}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sentiment(text: str) -> int:
|
|
60
|
+
"""-1 / 0 / 1 from the shared phrase lexicon (testdata/agent-analytics/lexicon.json)."""
|
|
61
|
+
norm = _PUNCT.sub(" ", text.lower().replace("’", "'"))
|
|
62
|
+
padded = " " + _WS.sub(" ", norm).strip() + " "
|
|
63
|
+
neg = 0
|
|
64
|
+
for p in NEG:
|
|
65
|
+
if f" {p} " in padded:
|
|
66
|
+
neg += 1
|
|
67
|
+
padded = padded.replace(f" {p} ", " ")
|
|
68
|
+
pos = sum(1 for p in POS if f" {p} " in padded)
|
|
69
|
+
return -1 if neg > pos else 1 if pos > neg else 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _digits(s: str) -> int:
|
|
73
|
+
return sum(1 for c in s if "0" <= c <= "9")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _luhn(s: str) -> bool:
|
|
77
|
+
d = [ord(c) - 48 for c in s if "0" <= c <= "9"]
|
|
78
|
+
if not 13 <= len(d) <= 19:
|
|
79
|
+
return False
|
|
80
|
+
total = 0
|
|
81
|
+
for i, x in enumerate(reversed(d)):
|
|
82
|
+
total += (x * 2 - 9 if x > 4 else x * 2) if i % 2 == 1 else x
|
|
83
|
+
return total % 10 == 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Same detectors, order and replacement as crates/lc-replay/src/mask.rs (the worker re-applies it).
|
|
87
|
+
_RULES: tuple[tuple[re.Pattern[str], Callable[[str], bool]], ...] = (
|
|
88
|
+
(re.compile(r"\b\d(?:[ -]?\d){12,18}\b"), _luhn),
|
|
89
|
+
(re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}"), lambda _: True),
|
|
90
|
+
(re.compile(r"\+?\d[\d\s().-]{7,}\d"), lambda s: 10 <= _digits(s) <= 15),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def stars(s: str) -> str:
|
|
95
|
+
return "".join(c if c.isspace() else "*" for c in s)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def redact(text: str) -> str | None:
|
|
99
|
+
"""Masked copy (cards, emails, phones -> `*`, whitespace kept); None when nothing matched."""
|
|
100
|
+
cur = text
|
|
101
|
+
for rx, ok in _RULES:
|
|
102
|
+
|
|
103
|
+
def mask(m: re.Match[str], ok: Callable[[str], bool] = ok) -> str:
|
|
104
|
+
return stars(m[0]) if ok(m[0]) else m[0]
|
|
105
|
+
|
|
106
|
+
cur = rx.sub(mask, cur)
|
|
107
|
+
return None if cur == text else cur
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def trace_id(v: object) -> str | None:
|
|
111
|
+
"""A W3C trace id (32 lowercase hex) or None."""
|
|
112
|
+
if not isinstance(v, str):
|
|
113
|
+
return None
|
|
114
|
+
t = v.lower()
|
|
115
|
+
return t if _TRACE.fullmatch(t) else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def outcome(v: object) -> str | None:
|
|
119
|
+
return v if isinstance(v, str) and v in OUTCOMES else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _label(v: object, n: int) -> str | None:
|
|
123
|
+
return v[:n] if isinstance(v, str) and v else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _text_props(key: str, text: str, cap: int) -> Props:
|
|
127
|
+
masked = redact(text[:cap])
|
|
128
|
+
return {key: masked if masked is not None else text[:cap], **({"$ai_text_redacted": True} if masked is not None else {})}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def message_props(role: str, text: str, index: int, capture_text: bool) -> Props | None:
|
|
132
|
+
"""`$ai_message` props, None for a role other than user/assistant."""
|
|
133
|
+
if role not in ROLES:
|
|
134
|
+
return None
|
|
135
|
+
p: Props = {"$ai_role": role, "$ai_message_index": index, "$ai_text_length": len(text)}
|
|
136
|
+
if role == "user":
|
|
137
|
+
h = text_hash(text)
|
|
138
|
+
if h is not None:
|
|
139
|
+
p["$ai_text_hash"] = h
|
|
140
|
+
p["$ai_sentiment"] = sentiment(text)
|
|
141
|
+
if capture_text:
|
|
142
|
+
p.update(_text_props("$ai_text", text, MAX_TEXT))
|
|
143
|
+
return p
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def feedback_props(
|
|
147
|
+
kind: str, value: float | None, comment: str | None, message_index: int | None, capture_text: bool
|
|
148
|
+
) -> Props | None:
|
|
149
|
+
"""`$ai_feedback` props, None when kind/value is invalid (thumbs 1|-1, rating integer 1..5)."""
|
|
150
|
+
if kind == "thumbs" and value not in (1, -1):
|
|
151
|
+
return None
|
|
152
|
+
if kind == "rating" and (isinstance(value, bool) or not isinstance(value, (int, float)) or not 1 <= value <= 5 or value != int(value)):
|
|
153
|
+
return None
|
|
154
|
+
if kind not in FEEDBACK_KINDS:
|
|
155
|
+
return None
|
|
156
|
+
p: Props = {"$ai_feedback_kind": kind}
|
|
157
|
+
if kind != "comment":
|
|
158
|
+
p["$ai_feedback_value"] = int(value) # type: ignore[arg-type]
|
|
159
|
+
if comment and capture_text:
|
|
160
|
+
masked = redact(comment[:MAX_COMMENT])
|
|
161
|
+
p["$ai_feedback_comment"] = masked if masked is not None else comment[:MAX_COMMENT]
|
|
162
|
+
if message_index is not None:
|
|
163
|
+
p["$ai_message_index"] = message_index
|
|
164
|
+
return p
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def outcome_props(o: str | None, resolution: str | None) -> Props:
|
|
168
|
+
p: Props = {}
|
|
169
|
+
if (v := outcome(o)) is not None:
|
|
170
|
+
p["$ai_outcome"] = v
|
|
171
|
+
if (r := _label(resolution, 64)) is not None:
|
|
172
|
+
p["$ai_resolution"] = r
|
|
173
|
+
return p
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def common_props(
|
|
177
|
+
conversation_id: str | None = None,
|
|
178
|
+
agent_name: str | None = None,
|
|
179
|
+
agent_version: str | None = None,
|
|
180
|
+
trace: str | None = None,
|
|
181
|
+
topic: str | None = None,
|
|
182
|
+
use_case: str | None = None,
|
|
183
|
+
) -> Props:
|
|
184
|
+
p: Props = {"$ai_conversation_id": conversation_id or str(uuid.uuid4())}
|
|
185
|
+
for key, v, n in (("$ai_agent_name", agent_name, 128), ("$ai_agent_version", agent_version, 64),
|
|
186
|
+
("$ai_topic", topic, 128), ("$ai_use_case", use_case, 128)): # fmt: skip
|
|
187
|
+
if (s := _label(v, n)) is not None:
|
|
188
|
+
p[key] = s
|
|
189
|
+
if (t := trace_id(trace)) is not None:
|
|
190
|
+
p["$ai_trace_id"] = t
|
|
191
|
+
return p
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AgentConversation:
|
|
195
|
+
"""One agent conversation; every event goes through the client's normal `capture`."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, emit: Emit, common: Props, capture_text: bool) -> None:
|
|
198
|
+
self._emit, self._common, self._capture_text = emit, common, capture_text
|
|
199
|
+
self._next = 0
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def id(self) -> str:
|
|
203
|
+
return str(self._common["$ai_conversation_id"])
|
|
204
|
+
|
|
205
|
+
def _send(self, event: str, props: Props) -> None:
|
|
206
|
+
self._emit(event, {**self._common, **props})
|
|
207
|
+
|
|
208
|
+
def start(self) -> AgentConversation:
|
|
209
|
+
self._send("$ai_conversation_started", {})
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def message(self, role: str, text: str, index: int | None = None) -> bool:
|
|
213
|
+
"""`$ai_message`; the index auto-increments per conversation unless given. False (no event) for a bad role."""
|
|
214
|
+
i = self._next if index is None else index
|
|
215
|
+
p = message_props(role, text, i, self._capture_text)
|
|
216
|
+
if p is None:
|
|
217
|
+
return False
|
|
218
|
+
self._next = i + 1
|
|
219
|
+
self._send("$ai_message", p)
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
def feedback(
|
|
223
|
+
self, kind: str, value: float | None = None, comment: str | None = None, message_index: int | None = None
|
|
224
|
+
) -> bool:
|
|
225
|
+
p = feedback_props(kind, value, comment, message_index, self._capture_text)
|
|
226
|
+
if p is None:
|
|
227
|
+
return False
|
|
228
|
+
self._send("$ai_feedback", p)
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
def end(self, outcome: str | None = None, resolution: str | None = None) -> None:
|
|
232
|
+
self._send("$ai_conversation_ended", outcome_props(outcome, resolution))
|
|
233
|
+
|
|
234
|
+
def outcome(self, outcome: str, resolution: str | None = None) -> bool:
|
|
235
|
+
"""A resolution known later (e.g. the support ticket closed). False (no event) for an unknown outcome."""
|
|
236
|
+
p = outcome_props(outcome, resolution)
|
|
237
|
+
if "$ai_outcome" not in p:
|
|
238
|
+
return False
|
|
239
|
+
self._send("$ai_outcome", p)
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def start_conversation(
|
|
244
|
+
capture: Callable[..., None],
|
|
245
|
+
distinct_id: str | None,
|
|
246
|
+
capture_text: bool,
|
|
247
|
+
*,
|
|
248
|
+
conversation_id: str | None = None,
|
|
249
|
+
agent_name: str | None = None,
|
|
250
|
+
agent_version: str | None = None,
|
|
251
|
+
trace_id: str | None = None,
|
|
252
|
+
topic: str | None = None,
|
|
253
|
+
use_case: str | None = None,
|
|
254
|
+
) -> AgentConversation:
|
|
255
|
+
"""Shared by the sync and asyncio clients: `capture(distinct_id, event, props)` is the client's normal capture."""
|
|
256
|
+
common = common_props(conversation_id, agent_name, agent_version, trace_id, topic, use_case)
|
|
257
|
+
return AgentConversation(lambda event, props: capture(distinct_id, event, props), common, capture_text).start()
|
lucida/_ai_config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""AI Config flags (LC-FLG-25, P-M49): render the served variation of an `ai_config` flag.
|
|
2
|
+
|
|
3
|
+
A variation is completion mode (chat ``messages``) or agent mode (``instructions`` plus ``tools``).
|
|
4
|
+
The SDK only selects and renders it; evaluating model output is Watchtower's job. Mirrors
|
|
5
|
+
``packages/sdk-core/src/ai-config.ts``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field, replace
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_VAR = re.compile(r"\{\{\s*([\w.-]+)\s*\}\}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fill(s: str, variables: dict[str, str]) -> str:
|
|
18
|
+
return _VAR.sub(lambda m: variables.get(m.group(1), m.group(0)), s)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AiConfig:
|
|
23
|
+
"""A rendered AI config. ``enabled`` is False when the flag served nothing and this is the fallback."""
|
|
24
|
+
|
|
25
|
+
model: str
|
|
26
|
+
enabled: bool = False
|
|
27
|
+
mode: str = "completion"
|
|
28
|
+
provider: str | None = None
|
|
29
|
+
messages: list[dict[str, str]] = field(default_factory=list)
|
|
30
|
+
instructions: str | None = None
|
|
31
|
+
tools: list[dict[str, Any]] = field(default_factory=list)
|
|
32
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
variation: str | None = None
|
|
34
|
+
version: int | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ai_config(variant: str | None, payload: Any, fallback: AiConfig, variables: dict[str, str] | None = None) -> AiConfig:
|
|
38
|
+
"""The config served as ``variant`` with ``payload`` (or ``fallback``), ``{{var}}`` placeholders filled."""
|
|
39
|
+
v = variables or {}
|
|
40
|
+
p = payload if isinstance(payload, dict) else None
|
|
41
|
+
if variant and p is not None and isinstance(p.get("model"), str):
|
|
42
|
+
msgs = p.get("messages")
|
|
43
|
+
tools = p.get("tools")
|
|
44
|
+
instr = p.get("instructions")
|
|
45
|
+
version = p.get("version")
|
|
46
|
+
base = AiConfig(
|
|
47
|
+
model=p["model"],
|
|
48
|
+
enabled=True,
|
|
49
|
+
mode="agent" if p.get("mode") == "agent" else "completion",
|
|
50
|
+
provider=p.get("provider") or None,
|
|
51
|
+
messages=list(msgs) if isinstance(msgs, list) else [],
|
|
52
|
+
instructions=instr if isinstance(instr, str) else None,
|
|
53
|
+
tools=list(tools) if isinstance(tools, list) else [],
|
|
54
|
+
parameters=dict(p.get("parameters") or {}),
|
|
55
|
+
variation=variant,
|
|
56
|
+
version=version if isinstance(version, int) and not isinstance(version, bool) else None,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
mode = fallback.mode if fallback.mode != "completion" or fallback.instructions is None else "agent"
|
|
60
|
+
base = replace(fallback, enabled=False, mode=mode)
|
|
61
|
+
return replace(
|
|
62
|
+
base,
|
|
63
|
+
messages=[{"role": m["role"], "content": _fill(m["content"], v)} for m in base.messages],
|
|
64
|
+
instructions=_fill(base.instructions, v) if base.instructions is not None else None,
|
|
65
|
+
)
|
lucida/_build.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Event assembly shared by the sync and asyncio clients: context merge, super properties, personless, geoip, before_send."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import uuid as uuidlib
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._context import get_context
|
|
14
|
+
from ._core import LruSet, Props, WireEvent, make_event
|
|
15
|
+
|
|
16
|
+
IDENTITY_DEDUPE_SIZE = 1000
|
|
17
|
+
|
|
18
|
+
BeforeSend = Callable[[WireEvent], "WireEvent | None"]
|
|
19
|
+
OnError = Callable[[Exception, "list[WireEvent]"], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def notify(on_error: OnError | None, err: Exception, events: list[WireEvent]) -> None:
|
|
23
|
+
"""Call the user's error callback; a raising callback must never break delivery."""
|
|
24
|
+
if on_error is None:
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
on_error(err, events)
|
|
28
|
+
except Exception: # noqa: BLE001
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EventBuilder:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
before_send: BeforeSend | None = None,
|
|
37
|
+
on_error: OnError | None = None,
|
|
38
|
+
disable_geoip: bool = False,
|
|
39
|
+
is_server: bool = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.before_send, self.on_error = before_send, on_error
|
|
42
|
+
self.disable_geoip = disable_geoip
|
|
43
|
+
self.super_props: dict[str, Any] = {}
|
|
44
|
+
self._is_server = is_server
|
|
45
|
+
self._identities = LruSet(IDENTITY_DEDUPE_SIZE)
|
|
46
|
+
self._identities_lock = threading.Lock()
|
|
47
|
+
|
|
48
|
+
def first_identity(self, *parts: Any) -> bool:
|
|
49
|
+
"""True the first time this exact identify/group call (id + props) is seen in the process (bounded LRU)."""
|
|
50
|
+
key = hashlib.sha256(json.dumps(parts, sort_keys=True, default=str).encode()).hexdigest()
|
|
51
|
+
with self._identities_lock:
|
|
52
|
+
return self._identities.add(key)
|
|
53
|
+
|
|
54
|
+
def register(self, properties: Props) -> None:
|
|
55
|
+
"""Client-wide default properties; per-event and context properties override them."""
|
|
56
|
+
self.super_props.update(properties)
|
|
57
|
+
|
|
58
|
+
def unregister(self, key: str) -> None:
|
|
59
|
+
self.super_props.pop(key, None)
|
|
60
|
+
|
|
61
|
+
def build(
|
|
62
|
+
self,
|
|
63
|
+
distinct_id: str | None,
|
|
64
|
+
event: str,
|
|
65
|
+
properties: Props | None = None,
|
|
66
|
+
*,
|
|
67
|
+
groups: Mapping[str, str] | None = None,
|
|
68
|
+
timestamp: datetime | str | None = None,
|
|
69
|
+
uuid: str | None = None,
|
|
70
|
+
set: Props | None = None, # noqa: A002
|
|
71
|
+
set_once: Props | None = None,
|
|
72
|
+
process_person_profile: bool = True,
|
|
73
|
+
disable_geoip: bool | None = None,
|
|
74
|
+
) -> WireEvent | None:
|
|
75
|
+
"""The wire event, or None when `before_send` dropped it. Explicit arguments beat the context."""
|
|
76
|
+
ctx = get_context()
|
|
77
|
+
distinct_id = distinct_id or (ctx.distinct_id if ctx else None)
|
|
78
|
+
if not distinct_id:
|
|
79
|
+
if process_person_profile:
|
|
80
|
+
raise ValueError("distinct_id is required (pass one, or use a context with identify_context)")
|
|
81
|
+
distinct_id = str(uuidlib.uuid4())
|
|
82
|
+
props: dict[str, Any] = dict(self.super_props)
|
|
83
|
+
if self._is_server:
|
|
84
|
+
props["$is_server"] = True
|
|
85
|
+
if ctx:
|
|
86
|
+
props.update(ctx.properties)
|
|
87
|
+
if ctx.session_id:
|
|
88
|
+
props["$session_id"] = ctx.session_id
|
|
89
|
+
props.update(properties or {})
|
|
90
|
+
if not process_person_profile:
|
|
91
|
+
props["$process_person_profile"] = False
|
|
92
|
+
set = set_once = None # noqa: A001 - ignored by the worker on personless events
|
|
93
|
+
if self.disable_geoip if disable_geoip is None else disable_geoip:
|
|
94
|
+
props["$geoip_disable"] = True
|
|
95
|
+
e = make_event(
|
|
96
|
+
distinct_id, event, props, groups=groups, timestamp=timestamp, uuid=uuid, set=set, set_once=set_once
|
|
97
|
+
)
|
|
98
|
+
if self.before_send is None:
|
|
99
|
+
return e
|
|
100
|
+
try:
|
|
101
|
+
return self.before_send(e)
|
|
102
|
+
except Exception as err: # noqa: BLE001
|
|
103
|
+
notify(self.on_error, err, [e])
|
|
104
|
+
return e # a raising hook keeps the original event
|
lucida/_context.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Ambient request context on contextvars (works across threads' own contexts and asyncio tasks).
|
|
2
|
+
|
|
3
|
+
Explicit capture arguments always win over context values. Context properties are merged under the
|
|
4
|
+
event's own properties; `distinct_id` and `$session_id` fill in only when the call gave none.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextvars
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, TypeVar, overload
|
|
17
|
+
|
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
19
|
+
|
|
20
|
+
DISTINCT_ID_HEADER = "X-Lucida-Distinct-Id"
|
|
21
|
+
SESSION_ID_HEADER = "X-Lucida-Session-Id"
|
|
22
|
+
_MAX_HEADER = 1000
|
|
23
|
+
_CTRL = re.compile(r"[\x00-\x1f\x7f]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Context:
|
|
28
|
+
distinct_id: str | None = None
|
|
29
|
+
session_id: str | None = None
|
|
30
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_current: contextvars.ContextVar[Context | None] = contextvars.ContextVar("lucida_context", default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_context() -> Context | None:
|
|
37
|
+
"""The innermost active context, or None."""
|
|
38
|
+
return _current.get()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def new_context(
|
|
43
|
+
*,
|
|
44
|
+
fresh: bool = False,
|
|
45
|
+
distinct_id: str | None = None,
|
|
46
|
+
session_id: str | None = None,
|
|
47
|
+
properties: Mapping[str, Any] | None = None,
|
|
48
|
+
) -> Iterator[Context]:
|
|
49
|
+
"""Open a context. Nested contexts inherit the parent's values unless `fresh=True`; changes never leak out."""
|
|
50
|
+
parent = None if fresh else _current.get()
|
|
51
|
+
ctx = Context(
|
|
52
|
+
distinct_id if distinct_id is not None else (parent.distinct_id if parent else None),
|
|
53
|
+
session_id if session_id is not None else (parent.session_id if parent else None),
|
|
54
|
+
{**(parent.properties if parent else {}), **(properties or {})},
|
|
55
|
+
)
|
|
56
|
+
token = _current.set(ctx)
|
|
57
|
+
try:
|
|
58
|
+
yield ctx
|
|
59
|
+
finally:
|
|
60
|
+
_current.reset(token)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@overload
|
|
64
|
+
def scoped(fn: F) -> F: ...
|
|
65
|
+
@overload
|
|
66
|
+
def scoped(*, fresh: bool = ...) -> Callable[[F], F]: ...
|
|
67
|
+
def scoped(fn: F | None = None, *, fresh: bool = False) -> Any:
|
|
68
|
+
"""Decorator: run the function (sync or async) in its own context. `@scoped` or `@scoped(fresh=True)`."""
|
|
69
|
+
|
|
70
|
+
def wrap(f: F) -> F:
|
|
71
|
+
if inspect.iscoroutinefunction(f):
|
|
72
|
+
|
|
73
|
+
@functools.wraps(f)
|
|
74
|
+
async def aw(*a: Any, **k: Any) -> Any:
|
|
75
|
+
with new_context(fresh=fresh):
|
|
76
|
+
return await f(*a, **k)
|
|
77
|
+
|
|
78
|
+
return aw # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
@functools.wraps(f)
|
|
81
|
+
def sw(*a: Any, **k: Any) -> Any:
|
|
82
|
+
with new_context(fresh=fresh):
|
|
83
|
+
return f(*a, **k)
|
|
84
|
+
|
|
85
|
+
return sw # type: ignore[return-value]
|
|
86
|
+
|
|
87
|
+
return wrap(fn) if fn is not None else wrap
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def tag(key: str, value: Any) -> None:
|
|
91
|
+
"""Add a property to the current context (no-op outside one)."""
|
|
92
|
+
if (c := _current.get()) is not None:
|
|
93
|
+
c.properties[key] = value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def identify_context(distinct_id: str) -> None:
|
|
97
|
+
if (c := _current.get()) is not None:
|
|
98
|
+
c.distinct_id = distinct_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def set_context_session(session_id: str) -> None:
|
|
102
|
+
if (c := _current.get()) is not None:
|
|
103
|
+
c.session_id = session_id
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def clean_header(v: str | None) -> str | None:
|
|
107
|
+
"""Strip ASCII control chars, trim, cap at 1000 chars; empty -> None."""
|
|
108
|
+
if not v:
|
|
109
|
+
return None
|
|
110
|
+
return _CTRL.sub("", v).strip()[:_MAX_HEADER] or None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_tracing_headers(get: Callable[[str], str | None]) -> tuple[str | None, str | None]:
|
|
114
|
+
"""(distinct_id, session_id) from the browser SDK's tracing headers. `get` looks a header up by name.
|
|
115
|
+
|
|
116
|
+
These headers are client-controlled: use them to join events to a browser session, never for authentication.
|
|
117
|
+
"""
|
|
118
|
+
return clean_header(get(DISTINCT_ID_HEADER)), clean_header(get(SESSION_ID_HEADER))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def request_properties(
|
|
122
|
+
*, url: str | None, method: str | None, path: str | None, user_agent: str | None, ip: str | None
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""The request props middleware stamps on the context (absent values are left out)."""
|
|
125
|
+
raw = {"$current_url": url, "$request_method": method, "$request_path": path, "$user_agent": user_agent, "$ip": ip}
|
|
126
|
+
return {k: v for k, v in raw.items() if v}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def client_ip(remote_addr: str | None, forwarded_for: str | None, trust_proxy: bool) -> str | None:
|
|
130
|
+
"""First X-Forwarded-For entry only when `trust_proxy`; else the socket peer."""
|
|
131
|
+
if trust_proxy and forwarded_for:
|
|
132
|
+
first = forwarded_for.split(",")[0].strip()
|
|
133
|
+
if first:
|
|
134
|
+
return first
|
|
135
|
+
return remote_addr
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def request_context_args(
|
|
139
|
+
get_header: Callable[[str], str | None],
|
|
140
|
+
*,
|
|
141
|
+
method: str | None,
|
|
142
|
+
url: str | None,
|
|
143
|
+
path: str | None,
|
|
144
|
+
remote_addr: str | None,
|
|
145
|
+
trust_proxy: bool = False,
|
|
146
|
+
capture_tracing_headers: bool = True,
|
|
147
|
+
extra: Mapping[str, Any] | None = None,
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
"""Keyword arguments for `new_context(fresh=True, **args)`: tracing-header ids plus the stamped request props."""
|
|
150
|
+
did, sid = read_tracing_headers(get_header) if capture_tracing_headers else (None, None)
|
|
151
|
+
props = request_properties(
|
|
152
|
+
url=url,
|
|
153
|
+
method=method,
|
|
154
|
+
path=path,
|
|
155
|
+
user_agent=clean_header(get_header("User-Agent")),
|
|
156
|
+
ip=client_ip(remote_addr, get_header("X-Forwarded-For"), trust_proxy),
|
|
157
|
+
)
|
|
158
|
+
return {"distinct_id": did, "session_id": sid, "properties": {**props, **(extra or {})}}
|