openscript-sdk 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.
- openscript_sdk/__init__.py +217 -0
- openscript_sdk/contracts/__init__.py +82 -0
- openscript_sdk/contracts/interceptor.py +98 -0
- openscript_sdk/contracts/server_types.py +87 -0
- openscript_sdk/contracts/session_graph.py +123 -0
- openscript_sdk/contracts/types.py +76 -0
- openscript_sdk/events/__init__.py +0 -0
- openscript_sdk/events/approvals.py +309 -0
- openscript_sdk/events/models.py +26 -0
- openscript_sdk/events/store.py +79 -0
- openscript_sdk/events/writer.py +100 -0
- openscript_sdk/hooks/claude_code.py +479 -0
- openscript_sdk/hooks/claude_code_example.yaml +62 -0
- openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
- openscript_sdk/hooks/config.py +144 -0
- openscript_sdk/hooks/settings_example.json +28 -0
- openscript_sdk/hooks/settings_strict_example.json +28 -0
- openscript_sdk/integrations/langchain.py +32 -0
- openscript_sdk/integrations/langgraph.py +32 -0
- openscript_sdk/interceptors/base.py +15 -0
- openscript_sdk/interceptors/event_writer.py +131 -0
- openscript_sdk/interceptors/pii.py +168 -0
- openscript_sdk/interceptors/threat.py +674 -0
- openscript_sdk/logging.py +94 -0
- openscript_sdk/middleware/middleware.py +543 -0
- openscript_sdk/middleware/tool.py +247 -0
- openscript_sdk/observability/__init__.py +11 -0
- openscript_sdk/observability/metrics.py +105 -0
- openscript_sdk/observability/risk.py +54 -0
- openscript_sdk/observability/tracing.py +67 -0
- openscript_sdk/policies/compliance.py +261 -0
- openscript_sdk/policies/config.py +244 -0
- openscript_sdk/policies/harmful_request.py +617 -0
- openscript_sdk/policies/normalize.py +489 -0
- openscript_sdk/policies/output_schema.py +353 -0
- openscript_sdk/policies/policies_example.yaml +36 -0
- openscript_sdk/policies/redaction.py +359 -0
- openscript_sdk/policies/secrets.py +239 -0
- openscript_sdk/policies/semantic.py +348 -0
- openscript_sdk/policies/semantic_head.py +9 -0
- openscript_sdk/policies/session_taint.py +399 -0
- openscript_sdk/policies/taint_store.py +111 -0
- openscript_sdk/policies/tool_firewall.py +434 -0
- openscript_sdk/policies/tools_example.yaml +81 -0
- openscript_sdk/policies/toxicity.py +247 -0
- openscript_sdk/py.typed +0 -0
- openscript_sdk/server/__init__.py +1 -0
- openscript_sdk/server/app.py +153 -0
- openscript_sdk/server/auth.py +19 -0
- openscript_sdk/server/guard.py +126 -0
- openscript_sdk/server/guard_example.yaml +55 -0
- openscript_sdk/server/routers/__init__.py +0 -0
- openscript_sdk/server/routers/approvals.py +69 -0
- openscript_sdk/server/routers/guard.py +231 -0
- openscript_sdk/server/routers/sessions.py +80 -0
- openscript_sdk/server/routers/sse.py +69 -0
- openscript_sdk/server/routers/threat.py +119 -0
- openscript_sdk/server/static/dashboard/index.html +209 -0
- openscript_sdk/server/static/dashboard/session.html +339 -0
- openscript_sdk-0.1.0.dist-info/METADATA +225 -0
- openscript_sdk-0.1.0.dist-info/RECORD +65 -0
- openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
- openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
- openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""SemanticPolicy — the opt-in layer that reads a sentence instead of matching it.
|
|
2
|
+
|
|
3
|
+
The pattern banks recognise shapes. Measured against attacks phrased in words
|
|
4
|
+
nobody wrote a rule for, they block almost nothing (docs/metrics.md), and no
|
|
5
|
+
amount of extra patterns changes that. This policy is the other half: it embeds
|
|
6
|
+
the prompt with a small sentence encoder and scores the embedding with a linear
|
|
7
|
+
head trained on labelled attacks and the benign prompts that resemble them.
|
|
8
|
+
|
|
9
|
+
It is **not** one of the pure-local built-ins and is never part of the browser
|
|
10
|
+
demo. It needs the ``[ml]`` extra, and the encoder weights are downloaded from
|
|
11
|
+
the Hugging Face Hub the first time the model loads (pass
|
|
12
|
+
``local_files_only=True`` once they are cached to rule the network out). Every
|
|
13
|
+
heavy import is inside a function, so ``import openscript_sdk`` stays exactly as light as
|
|
14
|
+
it was.
|
|
15
|
+
|
|
16
|
+
Three pieces, kept separate so each can be checked on its own:
|
|
17
|
+
|
|
18
|
+
- an **encoder**: any callable mapping a list of strings to unit-length vectors.
|
|
19
|
+
The default wraps sentence-transformers; tests pass a stub.
|
|
20
|
+
- a **head**: class weights for {benign, prompt_injection, harmful_request,
|
|
21
|
+
toxicity}, shipped as plain numbers in ``semantic_head.py`` and applied in
|
|
22
|
+
pure Python. ``tools/train_semantic_head.py`` regenerates it.
|
|
23
|
+
- the **policy**: scores every normalized view and every window of the text,
|
|
24
|
+
keeps the strongest, and denies (or annotates) at the head's calibrated
|
|
25
|
+
threshold.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import importlib.util
|
|
32
|
+
import math
|
|
33
|
+
from collections.abc import Callable, Sequence
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from enum import StrEnum
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
import structlog
|
|
39
|
+
|
|
40
|
+
from openscript_sdk.contracts.interceptor import BasePolicy
|
|
41
|
+
from openscript_sdk.contracts.server_types import Event, EventType
|
|
42
|
+
from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
|
|
43
|
+
from openscript_sdk.policies import normalize
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from openscript_sdk.events.writer import EventWriter
|
|
47
|
+
|
|
48
|
+
logger = structlog.get_logger(__name__)
|
|
49
|
+
|
|
50
|
+
#: Texts in, one unit-length vector per text out.
|
|
51
|
+
Encoder = Callable[[list[str]], Sequence[Sequence[float]]]
|
|
52
|
+
|
|
53
|
+
BENIGN = "benign"
|
|
54
|
+
|
|
55
|
+
# The encoder truncates at its own token limit, so a long prompt is scored in
|
|
56
|
+
# overlapping windows and the strongest window decides — an instruction buried
|
|
57
|
+
# on page three is still an instruction. The cap bounds the cost of one call.
|
|
58
|
+
WINDOW_WORDS = 120
|
|
59
|
+
WINDOW_STRIDE = 80
|
|
60
|
+
MAX_WINDOWS = 24
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SemanticMode(StrEnum):
|
|
64
|
+
DENY = "deny"
|
|
65
|
+
ANNOTATE = "annotate"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class SemanticHead:
|
|
70
|
+
"""A linear classifier over one specific encoder's embedding space."""
|
|
71
|
+
|
|
72
|
+
model: str
|
|
73
|
+
labels: tuple[str, ...]
|
|
74
|
+
weights: tuple[tuple[float, ...], ...]
|
|
75
|
+
bias: tuple[float, ...]
|
|
76
|
+
threshold: float
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
if BENIGN not in self.labels:
|
|
80
|
+
raise ValueError(f"head needs a {BENIGN!r} class, got {self.labels}")
|
|
81
|
+
if not (len(self.labels) == len(self.weights) == len(self.bias)):
|
|
82
|
+
raise ValueError("head labels, weights and bias disagree on the number of classes")
|
|
83
|
+
if len({len(row) for row in self.weights}) != 1:
|
|
84
|
+
raise ValueError("head weight rows have different lengths")
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def dim(self) -> int:
|
|
88
|
+
return len(self.weights[0])
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def trained(self) -> bool:
|
|
92
|
+
"""False for the all-zero placeholder that ships until a head is trained."""
|
|
93
|
+
return any(w != 0.0 for row in self.weights for w in row) or any(
|
|
94
|
+
b != 0.0 for b in self.bias
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: dict[str, Any]) -> SemanticHead:
|
|
99
|
+
return cls(
|
|
100
|
+
model=str(data["model"]),
|
|
101
|
+
labels=tuple(data["labels"]),
|
|
102
|
+
weights=tuple(tuple(float(w) for w in row) for row in data["weights"]),
|
|
103
|
+
bias=tuple(float(b) for b in data["bias"]),
|
|
104
|
+
threshold=float(data["threshold"]),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def probabilities(self, vector: Sequence[float]) -> dict[str, float]:
|
|
108
|
+
if len(vector) != self.dim:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"encoder produced {len(vector)}-d vectors; this head was trained on "
|
|
111
|
+
f"{self.dim}-d vectors from {self.model}"
|
|
112
|
+
)
|
|
113
|
+
logits = [
|
|
114
|
+
sum(w * x for w, x in zip(row, vector, strict=True)) + b
|
|
115
|
+
for row, b in zip(self.weights, self.bias, strict=True)
|
|
116
|
+
]
|
|
117
|
+
peak = max(logits)
|
|
118
|
+
exps = [math.exp(v - peak) for v in logits]
|
|
119
|
+
total = sum(exps)
|
|
120
|
+
return {label: e / total for label, e in zip(self.labels, exps, strict=True)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def default_head() -> SemanticHead:
|
|
124
|
+
"""The head shipped with the package, trained by tools/train_semantic_head.py."""
|
|
125
|
+
from openscript_sdk.policies.semantic_head import HEAD
|
|
126
|
+
|
|
127
|
+
return SemanticHead.from_dict(HEAD)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class SemanticScore:
|
|
132
|
+
"""The strongest reading of one prompt."""
|
|
133
|
+
|
|
134
|
+
risk: float
|
|
135
|
+
#: The most likely attack class, even when the risk is low.
|
|
136
|
+
predicted: str
|
|
137
|
+
probabilities: dict[str, float] = field(default_factory=dict)
|
|
138
|
+
view: str = "raw"
|
|
139
|
+
markers: list[str] = field(default_factory=list)
|
|
140
|
+
windows: int = 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def windows(text: str) -> list[str]:
|
|
144
|
+
"""Split *text* into overlapping word windows. Short text is one window."""
|
|
145
|
+
words = text.split()
|
|
146
|
+
if len(words) <= WINDOW_WORDS:
|
|
147
|
+
return [text]
|
|
148
|
+
starts = list(range(0, len(words) - WINDOW_WORDS + WINDOW_STRIDE, WINDOW_STRIDE))
|
|
149
|
+
if len(starts) > MAX_WINDOWS:
|
|
150
|
+
# Keep the ends and sample the middle evenly: openings and closings are
|
|
151
|
+
# where an instruction to the model usually sits.
|
|
152
|
+
step = (len(starts) - 1) / (MAX_WINDOWS - 1)
|
|
153
|
+
starts = sorted({starts[round(i * step)] for i in range(MAX_WINDOWS)})
|
|
154
|
+
return [" ".join(words[s : s + WINDOW_WORDS]) for s in starts]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SentenceTransformerEncoder:
|
|
158
|
+
"""The default encoder. Loads the model on first use, not on import."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, model_name: str, local_files_only: bool = False) -> None:
|
|
161
|
+
self.model_name = model_name
|
|
162
|
+
self._local_files_only = local_files_only
|
|
163
|
+
self._model: Any = None
|
|
164
|
+
|
|
165
|
+
def __call__(self, texts: list[str]) -> Sequence[Sequence[float]]:
|
|
166
|
+
if self._model is None:
|
|
167
|
+
from sentence_transformers import SentenceTransformer
|
|
168
|
+
|
|
169
|
+
self._model = SentenceTransformer(
|
|
170
|
+
self.model_name, local_files_only=self._local_files_only
|
|
171
|
+
)
|
|
172
|
+
vectors = self._model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
|
|
173
|
+
return vectors.tolist() # type: ignore[no-any-return]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SemanticScorer:
|
|
177
|
+
"""Encoder + head. Stateless apart from the loaded model; safe to share."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, encoder: Encoder, head: SemanticHead) -> None:
|
|
180
|
+
self._encoder = encoder
|
|
181
|
+
self.head = head
|
|
182
|
+
|
|
183
|
+
def score(self, text: str) -> SemanticScore:
|
|
184
|
+
prepared = normalize.prepare(text)
|
|
185
|
+
pieces: list[tuple[str, str]] = [] # (view name, window text)
|
|
186
|
+
for view in prepared.views:
|
|
187
|
+
pieces.extend((view.name, w) for w in windows(view.text) if w.strip())
|
|
188
|
+
if not pieces:
|
|
189
|
+
return SemanticScore(risk=0.0, predicted=BENIGN, markers=prepared.markers, windows=0)
|
|
190
|
+
|
|
191
|
+
vectors = self._encoder([p[1] for p in pieces])
|
|
192
|
+
best: SemanticScore | None = None
|
|
193
|
+
for (view_name, _), vector in zip(pieces, vectors, strict=True):
|
|
194
|
+
probs = self.head.probabilities(vector)
|
|
195
|
+
risk = 1.0 - probs[BENIGN]
|
|
196
|
+
# Strictly greater: ties go to the earliest view, so a prompt that
|
|
197
|
+
# reads the same before and after normalization is reported as raw.
|
|
198
|
+
if best is None or risk > best.risk:
|
|
199
|
+
attack = max((k for k in probs if k != BENIGN), key=lambda k: probs[k])
|
|
200
|
+
best = SemanticScore(
|
|
201
|
+
risk=risk,
|
|
202
|
+
predicted=attack,
|
|
203
|
+
probabilities=probs,
|
|
204
|
+
view=view_name,
|
|
205
|
+
markers=prepared.markers,
|
|
206
|
+
windows=len(pieces),
|
|
207
|
+
)
|
|
208
|
+
assert best is not None
|
|
209
|
+
return best
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class SemanticPolicy(BasePolicy):
|
|
213
|
+
"""Scores the meaning of the input and blocks at a calibrated threshold.
|
|
214
|
+
|
|
215
|
+
Opt-in. Needs ``pip install -e ".[ml]"`` unless an *encoder* is supplied,
|
|
216
|
+
and raises ImportError at construction rather than failing closed on every
|
|
217
|
+
request later. It also refuses to construct on an untrained head, which is
|
|
218
|
+
what the package ships until tools/train_semantic_head.py has been run: no
|
|
219
|
+
detection number is published for this policy yet. Place it after the pattern policies: they are a thousand
|
|
220
|
+
times cheaper and settle the obvious cases first.
|
|
221
|
+
|
|
222
|
+
Results land in context.metadata["semantic"] in the standardized shape:
|
|
223
|
+
{"risk": float, "category": "semantic", "predicted": str,
|
|
224
|
+
"probabilities": {...}, "flagged": bool, "threshold": float,
|
|
225
|
+
"model": str, "obfuscation": [...], "matched_view": str}
|
|
226
|
+
|
|
227
|
+
*threshold* defaults to the one calibrated when the head was trained (the
|
|
228
|
+
lowest that produced no false positive on held-out benign prompts). Lower
|
|
229
|
+
it and it catches more and blocks more ordinary text; that trade is
|
|
230
|
+
measured in docs/metrics.md, not assumed.
|
|
231
|
+
|
|
232
|
+
``mode="annotate"`` records the score without blocking, which is the right
|
|
233
|
+
first step in a deployment: read what it would have blocked before letting
|
|
234
|
+
it block.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
failure_mode: FailureMode = FailureMode.FAIL_CLOSED
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
threshold: float | None = None,
|
|
242
|
+
mode: SemanticMode | str = SemanticMode.DENY,
|
|
243
|
+
encoder: Encoder | None = None,
|
|
244
|
+
head: SemanticHead | None = None,
|
|
245
|
+
local_files_only: bool = False,
|
|
246
|
+
writer: EventWriter | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
self._head = head if head is not None else default_head()
|
|
249
|
+
if not self._head.trained:
|
|
250
|
+
# A policy that constructs, sits in the list and decides nothing is
|
|
251
|
+
# the worst outcome available: it reads as protection.
|
|
252
|
+
raise RuntimeError(
|
|
253
|
+
"SemanticPolicy has no trained head: openscript_sdk/policies/semantic_head.py is "
|
|
254
|
+
"still the placeholder. Run tools/train_semantic_head.py --write-head, "
|
|
255
|
+
"or pass head=..."
|
|
256
|
+
)
|
|
257
|
+
self._threshold = self._head.threshold if threshold is None else threshold
|
|
258
|
+
if not 0.0 <= self._threshold <= 1.0:
|
|
259
|
+
raise ValueError(f"threshold must be in [0, 1], got {self._threshold}")
|
|
260
|
+
self._mode = SemanticMode(mode)
|
|
261
|
+
if encoder is None:
|
|
262
|
+
if importlib.util.find_spec("sentence_transformers") is None:
|
|
263
|
+
raise ImportError(
|
|
264
|
+
"SemanticPolicy needs sentence-transformers: pip install -e '.[ml]' "
|
|
265
|
+
"(or pass encoder=...)"
|
|
266
|
+
)
|
|
267
|
+
encoder = SentenceTransformerEncoder(self._head.model, local_files_only)
|
|
268
|
+
self._scorer = SemanticScorer(encoder, self._head)
|
|
269
|
+
self._writer = writer
|
|
270
|
+
self._sequence_counters: dict[str, int] = {}
|
|
271
|
+
|
|
272
|
+
def score(self, text: str) -> SemanticScore:
|
|
273
|
+
"""Score one string synchronously — what before_action runs in a thread."""
|
|
274
|
+
return self._scorer.score(text)
|
|
275
|
+
|
|
276
|
+
async def before_action(self, context: ActionContext) -> ActionContext:
|
|
277
|
+
text = _extract_text(context.input_data)
|
|
278
|
+
# Encoding is CPU-bound for a few milliseconds; keep it off the loop.
|
|
279
|
+
result = await asyncio.to_thread(self._scorer.score, text)
|
|
280
|
+
flagged = result.risk >= self._threshold
|
|
281
|
+
|
|
282
|
+
context.metadata["semantic"] = {
|
|
283
|
+
"risk": round(result.risk, 4),
|
|
284
|
+
"category": "semantic",
|
|
285
|
+
"predicted": result.predicted,
|
|
286
|
+
"probabilities": {k: round(v, 4) for k, v in result.probabilities.items()},
|
|
287
|
+
"flagged": flagged,
|
|
288
|
+
"threshold": self._threshold,
|
|
289
|
+
"model": self._head.model,
|
|
290
|
+
"obfuscation": result.markers,
|
|
291
|
+
"matched_view": result.view,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if flagged and self._mode is SemanticMode.DENY:
|
|
295
|
+
reason = (
|
|
296
|
+
f"semantic score {result.risk:.2f} >= threshold {self._threshold:.2f} "
|
|
297
|
+
f"(reads as: {result.predicted})"
|
|
298
|
+
)
|
|
299
|
+
context.decision = InterceptorDecision.DENY
|
|
300
|
+
context.decision_reason = reason
|
|
301
|
+
logger.warning(
|
|
302
|
+
"semantic_detected",
|
|
303
|
+
session_id=context.session_id,
|
|
304
|
+
agent_id=context.agent_id,
|
|
305
|
+
score=round(result.risk, 4),
|
|
306
|
+
predicted=result.predicted,
|
|
307
|
+
)
|
|
308
|
+
if self._writer is not None:
|
|
309
|
+
await self._emit_event(context, result, reason)
|
|
310
|
+
|
|
311
|
+
return context
|
|
312
|
+
|
|
313
|
+
async def _emit_event(self, context: ActionContext, result: SemanticScore, reason: str) -> None:
|
|
314
|
+
assert self._writer is not None
|
|
315
|
+
count = self._sequence_counters.get(context.session_id, 0) + 1
|
|
316
|
+
self._sequence_counters[context.session_id] = count
|
|
317
|
+
await self._writer.write(
|
|
318
|
+
Event(
|
|
319
|
+
session_id=context.session_id,
|
|
320
|
+
agent_id=context.agent_id,
|
|
321
|
+
event_type=EventType.POLICY_EVALUATED,
|
|
322
|
+
payload={
|
|
323
|
+
"policy": "semantic_check",
|
|
324
|
+
"score": round(result.risk, 4),
|
|
325
|
+
"predicted": result.predicted,
|
|
326
|
+
"reason": reason,
|
|
327
|
+
"action": context.action,
|
|
328
|
+
"threshold": self._threshold,
|
|
329
|
+
"model": self._head.model,
|
|
330
|
+
},
|
|
331
|
+
sequence_num=count,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _extract_text(input_data: dict[str, Any]) -> str:
|
|
337
|
+
"""Pull a single string from common input_data shapes."""
|
|
338
|
+
if "input" in input_data:
|
|
339
|
+
return str(input_data["input"])
|
|
340
|
+
if "messages" in input_data:
|
|
341
|
+
parts = []
|
|
342
|
+
for msg in input_data["messages"]:
|
|
343
|
+
if isinstance(msg, dict):
|
|
344
|
+
parts.append(str(msg.get("content", "")))
|
|
345
|
+
else:
|
|
346
|
+
parts.append(str(msg))
|
|
347
|
+
return " ".join(parts)
|
|
348
|
+
return " ".join(str(v) for v in input_data.values())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Placeholder until tools/train_semantic_head.py writes the trained head."""
|
|
2
|
+
|
|
3
|
+
HEAD = {
|
|
4
|
+
"model": "sentence-transformers/all-MiniLM-L6-v2",
|
|
5
|
+
"labels": ["benign", "prompt_injection", "harmful_request", "toxicity"],
|
|
6
|
+
"weights": [[0.0], [0.0], [0.0], [0.0]],
|
|
7
|
+
"bias": [0.0, 0.0, 0.0, 0.0],
|
|
8
|
+
"threshold": 1.0,
|
|
9
|
+
}
|