pretext-sim 0.3.2__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.
- pretext/__init__.py +34 -0
- pretext/cli.py +555 -0
- pretext/client.py +220 -0
- pretext/data/example-profile.yaml +12 -0
- pretext/models.py +326 -0
- pretext/prompt.py +126 -0
- pretext/render.py +247 -0
- pretext_sim-0.3.2.dist-info/METADATA +259 -0
- pretext_sim-0.3.2.dist-info/RECORD +12 -0
- pretext_sim-0.3.2.dist-info/WHEEL +4 -0
- pretext_sim-0.3.2.dist-info/entry_points.txt +2 -0
- pretext_sim-0.3.2.dist-info/licenses/LICENSE +21 -0
pretext/client.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""HTTP client for any OpenAI-compatible chat completions endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from .models import APIError, Settings, Simulation, _format_validation
|
|
14
|
+
from .prompt import build_payload_messages
|
|
15
|
+
|
|
16
|
+
RETRY_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
|
|
17
|
+
MAX_RETRY_AFTER = 30.0
|
|
18
|
+
USER_AGENT = "pretext/0.1 (+authorized-training)"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_json(content: str) -> dict[str, Any]:
|
|
22
|
+
"""Pull a JSON object out of model output that may be wrapped or fenced."""
|
|
23
|
+
|
|
24
|
+
text = content.strip()
|
|
25
|
+
if text.startswith("```"):
|
|
26
|
+
text = text.split("\n", 1)[1] if "\n" in text else ""
|
|
27
|
+
if text.rstrip().endswith("```"):
|
|
28
|
+
text = text.rstrip()[:-3]
|
|
29
|
+
text = text.strip()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
parsed = json.loads(text)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
start = text.find("{")
|
|
35
|
+
end = text.rfind("}")
|
|
36
|
+
if start == -1 or end <= start:
|
|
37
|
+
raise APIError(
|
|
38
|
+
f"model returned no JSON object; first 200 chars: {content.strip()[:200]!r}"
|
|
39
|
+
) from None
|
|
40
|
+
try:
|
|
41
|
+
parsed = json.loads(text[start : end + 1])
|
|
42
|
+
except json.JSONDecodeError as exc:
|
|
43
|
+
raise APIError(f"model returned malformed JSON: {exc}") from None
|
|
44
|
+
|
|
45
|
+
if not isinstance(parsed, dict):
|
|
46
|
+
raise APIError(f"expected a JSON object, got {type(parsed).__name__}")
|
|
47
|
+
return parsed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _content_of(body: dict[str, Any]) -> str:
|
|
51
|
+
try:
|
|
52
|
+
choice = body["choices"][0]
|
|
53
|
+
message = choice["message"]
|
|
54
|
+
content = message["content"]
|
|
55
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
56
|
+
raise APIError(f"unexpected response shape: {str(body)[:200]}") from exc
|
|
57
|
+
|
|
58
|
+
if isinstance(content, list):
|
|
59
|
+
pieces = [
|
|
60
|
+
part.get("text", "")
|
|
61
|
+
for part in content
|
|
62
|
+
if isinstance(part, dict) and part.get("type") in (None, "text")
|
|
63
|
+
]
|
|
64
|
+
content = "".join(pieces)
|
|
65
|
+
|
|
66
|
+
if not isinstance(content, str) or not content.strip():
|
|
67
|
+
reason = choice.get("finish_reason") if isinstance(choice, dict) else None
|
|
68
|
+
if reason == "length":
|
|
69
|
+
raise APIError(
|
|
70
|
+
"model returned no content, only reasoning (finish_reason=length); "
|
|
71
|
+
"raise --max-tokens"
|
|
72
|
+
)
|
|
73
|
+
raise APIError("model returned empty content")
|
|
74
|
+
return content
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Client:
|
|
78
|
+
def __init__(self, settings: Settings, *, transport: httpx.BaseTransport | None = None) -> None:
|
|
79
|
+
self.settings = settings
|
|
80
|
+
self._transport = transport
|
|
81
|
+
self.last_usage: dict[str, Any] | None = None
|
|
82
|
+
|
|
83
|
+
def _payload(self, profile, channel: str | None, extra: str | None) -> dict[str, Any]:
|
|
84
|
+
payload: dict[str, Any] = {
|
|
85
|
+
"model": self.settings.model,
|
|
86
|
+
"messages": build_payload_messages(profile, channel=channel, extra=extra),
|
|
87
|
+
"temperature": self.settings.temperature,
|
|
88
|
+
"max_tokens": self.settings.max_tokens,
|
|
89
|
+
"response_format": {"type": "json_object"},
|
|
90
|
+
}
|
|
91
|
+
if self.settings.seed is not None:
|
|
92
|
+
payload["seed"] = self.settings.seed
|
|
93
|
+
return payload
|
|
94
|
+
|
|
95
|
+
def generate(
|
|
96
|
+
self,
|
|
97
|
+
profile,
|
|
98
|
+
*,
|
|
99
|
+
channel: str | None = None,
|
|
100
|
+
extra: str | None = None,
|
|
101
|
+
) -> Simulation:
|
|
102
|
+
body = self._request(self._payload(profile, channel, extra))
|
|
103
|
+
raw = extract_json(_content_of(body))
|
|
104
|
+
try:
|
|
105
|
+
return Simulation.model_validate(raw)
|
|
106
|
+
except ValidationError as exc:
|
|
107
|
+
raise APIError(
|
|
108
|
+
_format_validation(exc, prefix="model output failed validation")
|
|
109
|
+
) from exc
|
|
110
|
+
|
|
111
|
+
def _request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
112
|
+
headers = {
|
|
113
|
+
"Authorization": f"Bearer {self.settings.api_key}",
|
|
114
|
+
"Content-Type": "application/json",
|
|
115
|
+
"User-Agent": USER_AGENT,
|
|
116
|
+
}
|
|
117
|
+
attempts = self.settings.retries + 1
|
|
118
|
+
last: APIError | None = None
|
|
119
|
+
|
|
120
|
+
with httpx.Client(
|
|
121
|
+
timeout=self.settings.timeout,
|
|
122
|
+
transport=self._transport,
|
|
123
|
+
follow_redirects=True,
|
|
124
|
+
) as client:
|
|
125
|
+
for attempt in range(1, attempts + 1):
|
|
126
|
+
try:
|
|
127
|
+
response = client.post(self.settings.endpoint, json=payload, headers=headers)
|
|
128
|
+
except httpx.TimeoutException as exc:
|
|
129
|
+
last = APIError(f"request timed out after {self.settings.timeout:g}s")
|
|
130
|
+
if attempt < attempts:
|
|
131
|
+
time.sleep(_backoff(attempt, None))
|
|
132
|
+
continue
|
|
133
|
+
raise last from exc
|
|
134
|
+
except httpx.HTTPError as exc:
|
|
135
|
+
last = APIError(f"connection failed: {exc}")
|
|
136
|
+
if attempt < attempts:
|
|
137
|
+
time.sleep(_backoff(attempt, None))
|
|
138
|
+
continue
|
|
139
|
+
raise last from exc
|
|
140
|
+
|
|
141
|
+
if response.status_code in RETRY_STATUS and attempt < attempts:
|
|
142
|
+
time.sleep(_backoff(attempt, response.headers.get("retry-after")))
|
|
143
|
+
last = APIError(f"HTTP {response.status_code} from endpoint")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
last = None
|
|
147
|
+
body = _decode(response)
|
|
148
|
+
usage = body.get("usage")
|
|
149
|
+
self.last_usage = usage if isinstance(usage, dict) else None
|
|
150
|
+
return body
|
|
151
|
+
raise last or APIError("request failed")
|
|
152
|
+
|
|
153
|
+
def list_models(self) -> list[str]:
|
|
154
|
+
"""Fetch model ids from the endpoint; empty when unsupported."""
|
|
155
|
+
|
|
156
|
+
headers = {"Authorization": f"Bearer {self.settings.api_key}", "User-Agent": USER_AGENT}
|
|
157
|
+
with httpx.Client(timeout=self.settings.timeout, transport=self._transport) as client:
|
|
158
|
+
try:
|
|
159
|
+
base = self.settings.endpoint.rsplit("/", 2)[0]
|
|
160
|
+
response = client.get(f"{base}/models", headers=headers)
|
|
161
|
+
except httpx.HTTPError as exc:
|
|
162
|
+
raise APIError(f"model list unreachable: {exc}") from exc
|
|
163
|
+
if response.status_code == 404:
|
|
164
|
+
return []
|
|
165
|
+
body = _decode(response)
|
|
166
|
+
entries = body.get("data", [])
|
|
167
|
+
return [
|
|
168
|
+
str(entry["id"]) for entry in entries if isinstance(entry, dict) and entry.get("id")
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def ping_json(self) -> str:
|
|
172
|
+
"""Ask for one tiny JSON object; returns the raw content."""
|
|
173
|
+
|
|
174
|
+
payload = {
|
|
175
|
+
"model": self.settings.model,
|
|
176
|
+
"messages": [
|
|
177
|
+
{"role": "user", "content": 'Reply with exactly {"ok": true} and nothing else.'}
|
|
178
|
+
],
|
|
179
|
+
"max_tokens": 40,
|
|
180
|
+
"temperature": 0,
|
|
181
|
+
"response_format": {"type": "json_object"},
|
|
182
|
+
}
|
|
183
|
+
return _content_of(self._request(payload))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _backoff(attempt: int, retry_after: str | None) -> float:
|
|
187
|
+
if retry_after:
|
|
188
|
+
try:
|
|
189
|
+
return min(float(retry_after), MAX_RETRY_AFTER)
|
|
190
|
+
except ValueError:
|
|
191
|
+
pass
|
|
192
|
+
return min(0.5 * 2 ** (attempt - 1), 8.0) * (0.8 + random.random() * 0.4)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _decode(response: httpx.Response) -> dict[str, Any]:
|
|
196
|
+
if response.status_code >= 400:
|
|
197
|
+
raise APIError(f"HTTP {response.status_code}: {_error_text(response)}")
|
|
198
|
+
try:
|
|
199
|
+
body = response.json()
|
|
200
|
+
except (json.JSONDecodeError, httpx.DecodingError):
|
|
201
|
+
raise APIError(f"endpoint returned non-JSON: {response.text[:200]!r}") from None
|
|
202
|
+
if not isinstance(body, dict):
|
|
203
|
+
raise APIError("endpoint returned a non-object body")
|
|
204
|
+
return body
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _error_text(response: httpx.Response) -> str:
|
|
208
|
+
try:
|
|
209
|
+
body = response.json()
|
|
210
|
+
except (ValueError, httpx.DecodingError):
|
|
211
|
+
return response.text.strip()[:300] or "<empty body>"
|
|
212
|
+
if isinstance(body, dict):
|
|
213
|
+
error = body.get("error")
|
|
214
|
+
if isinstance(error, dict) and error.get("message"):
|
|
215
|
+
return str(error["message"])[:300]
|
|
216
|
+
if isinstance(error, str):
|
|
217
|
+
return error[:300]
|
|
218
|
+
if body.get("message"):
|
|
219
|
+
return str(body["message"])[:300]
|
|
220
|
+
return str(body)[:300]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name: Dana Iyer
|
|
2
|
+
role: Senior Payroll Analyst
|
|
3
|
+
company: Northwind Logistics
|
|
4
|
+
department: People Operations
|
|
5
|
+
tenure: 3 months
|
|
6
|
+
reports_to: Marta Kwan
|
|
7
|
+
communication_style: Brisk and formal, mixes payroll shorthand with chatty asides, mirrors whoever she is replying to
|
|
8
|
+
systems:
|
|
9
|
+
- Workday
|
|
10
|
+
- the payroll mailbox
|
|
11
|
+
- the bank-change portal
|
|
12
|
+
notes: New enough to the team to be unsure of process, still learning who owns what.
|
pretext/models.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Typed models for profiles, model payloads, and resolved settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import (
|
|
12
|
+
BaseModel,
|
|
13
|
+
ConfigDict,
|
|
14
|
+
Field,
|
|
15
|
+
HttpUrl,
|
|
16
|
+
ValidationError,
|
|
17
|
+
field_validator,
|
|
18
|
+
model_validator,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
ENV_PREFIX = "PRETEXT_"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PretextError(Exception):
|
|
25
|
+
"""Base class for expected, user-facing failures."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(PretextError):
|
|
29
|
+
"""Missing or contradictory configuration."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProfileError(PretextError):
|
|
33
|
+
"""A profile file could not be read or validated."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class APIError(PretextError):
|
|
37
|
+
"""The inference endpoint failed or returned something unusable."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Channel(str, Enum):
|
|
41
|
+
EMAIL = "email"
|
|
42
|
+
PHONE = "phone"
|
|
43
|
+
SMS = "sms"
|
|
44
|
+
CHAT = "chat"
|
|
45
|
+
IN_PERSON = "in_person"
|
|
46
|
+
LINKEDIN = "linkedin"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Profile(BaseModel):
|
|
50
|
+
"""The persona a simulation is built around."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
53
|
+
|
|
54
|
+
name: str = Field(min_length=2, max_length=80)
|
|
55
|
+
role: str = Field(min_length=2, max_length=120)
|
|
56
|
+
company: str = Field(min_length=2, max_length=120)
|
|
57
|
+
communication_style: str = Field(min_length=3, max_length=280)
|
|
58
|
+
department: str | None = Field(default=None, max_length=80)
|
|
59
|
+
tenure: str | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
max_length=60,
|
|
62
|
+
description="How long the persona has held the role, e.g. '3 months'.",
|
|
63
|
+
)
|
|
64
|
+
reports_to: str | None = Field(default=None, max_length=80)
|
|
65
|
+
systems: list[str] = Field(default_factory=list, max_length=12)
|
|
66
|
+
notes: str | None = Field(default=None, max_length=600)
|
|
67
|
+
|
|
68
|
+
@field_validator("systems")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _clean_systems(cls, values: list[str]) -> list[str]:
|
|
71
|
+
cleaned = [value.strip() for value in values if value.strip()]
|
|
72
|
+
if len(set(cleaned)) != len(cleaned):
|
|
73
|
+
raise ValueError("systems contains duplicates")
|
|
74
|
+
return cleaned
|
|
75
|
+
|
|
76
|
+
@field_validator("department", "tenure", "reports_to", "notes")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _blank_to_none(cls, value: str | None) -> str | None:
|
|
79
|
+
if value is None:
|
|
80
|
+
return None
|
|
81
|
+
stripped = value.strip()
|
|
82
|
+
return stripped or None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EscalationStep(BaseModel):
|
|
86
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
87
|
+
|
|
88
|
+
stage: Annotated[int, Field(ge=1, le=12)]
|
|
89
|
+
channel: Channel
|
|
90
|
+
action: str = Field(min_length=10, max_length=600)
|
|
91
|
+
goal: str = Field(min_length=5, max_length=300)
|
|
92
|
+
artifact: str | None = Field(
|
|
93
|
+
default=None,
|
|
94
|
+
max_length=300,
|
|
95
|
+
description="Concrete lure or prop used at this stage, if any.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@field_validator("artifact", mode="before")
|
|
99
|
+
@classmethod
|
|
100
|
+
def _trim_artifact(cls, value: Any) -> Any:
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
return value
|
|
103
|
+
trimmed = value.strip()
|
|
104
|
+
return trimmed if len(trimmed) <= 300 else f"{trimmed[:297].rstrip()}..."
|
|
105
|
+
|
|
106
|
+
@field_validator("stage", mode="before")
|
|
107
|
+
@classmethod
|
|
108
|
+
def _coerce_stage(cls, value: Any) -> Any:
|
|
109
|
+
if isinstance(value, str) and value.strip().isdigit():
|
|
110
|
+
return int(value.strip())
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RedFlag(BaseModel):
|
|
115
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
116
|
+
|
|
117
|
+
title: str = Field(min_length=4, max_length=120)
|
|
118
|
+
why_it_matters: str = Field(min_length=10, max_length=500)
|
|
119
|
+
defender_action: str = Field(min_length=5, max_length=300)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Simulation(BaseModel):
|
|
123
|
+
"""A complete, labeled simulation returned by the model."""
|
|
124
|
+
|
|
125
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
126
|
+
|
|
127
|
+
pretext: str = Field(min_length=80, max_length=2600)
|
|
128
|
+
opening_message: str = Field(min_length=20, max_length=1800)
|
|
129
|
+
subject_line: str | None = Field(default=None, max_length=140)
|
|
130
|
+
escalation_path: list[EscalationStep] = Field(min_length=2, max_length=8)
|
|
131
|
+
red_flags: list[RedFlag] = Field(min_length=2, max_length=8)
|
|
132
|
+
difficulty: Annotated[int, Field(ge=1, le=5)] = 3
|
|
133
|
+
objective: str = Field(default="", max_length=300)
|
|
134
|
+
|
|
135
|
+
@field_validator("objective", mode="before")
|
|
136
|
+
@classmethod
|
|
137
|
+
def _trim_objective(cls, value: Any) -> Any:
|
|
138
|
+
if not isinstance(value, str):
|
|
139
|
+
return value
|
|
140
|
+
trimmed = value.strip()
|
|
141
|
+
return trimmed if len(trimmed) <= 300 else f"{trimmed[:297].rstrip()}..."
|
|
142
|
+
|
|
143
|
+
@field_validator("opening_message")
|
|
144
|
+
@classmethod
|
|
145
|
+
def _strip_headers(cls, value: str) -> str:
|
|
146
|
+
lines = value.splitlines()
|
|
147
|
+
while lines and _is_header(lines[0]):
|
|
148
|
+
lines.pop(0)
|
|
149
|
+
while lines and not lines[0].strip():
|
|
150
|
+
lines.pop(0)
|
|
151
|
+
return "\n".join(lines).strip()
|
|
152
|
+
|
|
153
|
+
@model_validator(mode="after")
|
|
154
|
+
def _stages_are_ordered(self) -> Simulation:
|
|
155
|
+
stages = [step.stage for step in self.escalation_path]
|
|
156
|
+
if stages != list(range(1, len(stages) + 1)):
|
|
157
|
+
raise ValueError(f"escalation_path stages must run 1..n in order, got {stages}")
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
HEADER_PREFIXES = ("subject:", "from:", "to:", "cc:", "sent:")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_header(line: str) -> bool:
|
|
165
|
+
return line.strip().lower().startswith(HEADER_PREFIXES)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Settings(BaseModel):
|
|
169
|
+
"""Runtime configuration resolved from flags and environment."""
|
|
170
|
+
|
|
171
|
+
model_config = ConfigDict(frozen=True)
|
|
172
|
+
|
|
173
|
+
base_url: HttpUrl
|
|
174
|
+
model: str = Field(min_length=1, max_length=120)
|
|
175
|
+
api_key: str = Field(default="", repr=False)
|
|
176
|
+
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
|
177
|
+
timeout: float = Field(default=60.0, gt=0, le=600)
|
|
178
|
+
retries: int = Field(default=3, ge=0, le=8)
|
|
179
|
+
max_tokens: int = Field(default=3200, ge=256, le=16000)
|
|
180
|
+
seed: int | None = None
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def endpoint(self) -> str:
|
|
184
|
+
return f"{str(self.base_url).rstrip('/')}/chat/completions"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
188
|
+
DEFAULT_MODEL = "gpt-4o-mini"
|
|
189
|
+
|
|
190
|
+
PROVIDERS: dict[str, str] = {
|
|
191
|
+
"openai": "https://api.openai.com/v1",
|
|
192
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
193
|
+
"venice": "https://api.venice.ai/api/v1",
|
|
194
|
+
"ollama": "http://localhost:11434/v1",
|
|
195
|
+
"lmstudio": "http://localhost:1234/v1",
|
|
196
|
+
}
|
|
197
|
+
LOCAL_PROVIDERS = {"ollama", "lmstudio"}
|
|
198
|
+
ENV_FILE = ".env"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _env(key: str) -> str | None:
|
|
202
|
+
value = os.environ.get(key)
|
|
203
|
+
if value is None:
|
|
204
|
+
return None
|
|
205
|
+
stripped = value.strip()
|
|
206
|
+
return stripped or None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _dotenv_values(path: Path) -> dict[str, str]:
|
|
210
|
+
if not path.is_file():
|
|
211
|
+
return {}
|
|
212
|
+
found: dict[str, str] = {}
|
|
213
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
214
|
+
line = raw.strip()
|
|
215
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
216
|
+
continue
|
|
217
|
+
key, _, value = line.partition("=")
|
|
218
|
+
value = value.strip().strip("'\"")
|
|
219
|
+
if value:
|
|
220
|
+
found[key.strip()] = value
|
|
221
|
+
return found
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def resolve_settings(
|
|
225
|
+
*,
|
|
226
|
+
provider: str | None = None,
|
|
227
|
+
base_url: str | None = None,
|
|
228
|
+
model: str | None = None,
|
|
229
|
+
api_key: str | None = None,
|
|
230
|
+
temperature: float | None = None,
|
|
231
|
+
timeout: float | None = None,
|
|
232
|
+
retries: int | None = None,
|
|
233
|
+
max_tokens: int | None = None,
|
|
234
|
+
seed: int | None = None,
|
|
235
|
+
dotenv: Path | None = None,
|
|
236
|
+
) -> Settings:
|
|
237
|
+
"""Resolve settings with the precedence flag > env > .env > default."""
|
|
238
|
+
|
|
239
|
+
file_values = _dotenv_values(dotenv or Path.cwd() / ENV_FILE)
|
|
240
|
+
|
|
241
|
+
def pick(flag: str | None, env_name: str) -> str | None:
|
|
242
|
+
if flag is not None and flag.strip():
|
|
243
|
+
return flag.strip()
|
|
244
|
+
key = ENV_PREFIX + env_name
|
|
245
|
+
value = _env(key) or file_values.get(key)
|
|
246
|
+
return value or None
|
|
247
|
+
|
|
248
|
+
chosen = (provider or pick(None, "PROVIDER") or "").strip().lower() or None
|
|
249
|
+
if chosen is not None and chosen not in PROVIDERS:
|
|
250
|
+
raise ConfigError(f"unknown provider {chosen!r}; choose from {', '.join(PROVIDERS)}")
|
|
251
|
+
|
|
252
|
+
resolved_url = pick(base_url, "BASE_URL")
|
|
253
|
+
if resolved_url is None and chosen is not None:
|
|
254
|
+
resolved_url = PROVIDERS[chosen]
|
|
255
|
+
resolved_model = pick(model, "MODEL")
|
|
256
|
+
resolved_key = pick(api_key, "API_KEY") or _env("OPENAI_API_KEY")
|
|
257
|
+
if not resolved_key and chosen in LOCAL_PROVIDERS:
|
|
258
|
+
resolved_key = "local"
|
|
259
|
+
|
|
260
|
+
if resolved_url is None:
|
|
261
|
+
resolved_url = DEFAULT_BASE_URL
|
|
262
|
+
if resolved_model is None:
|
|
263
|
+
resolved_model = DEFAULT_MODEL
|
|
264
|
+
|
|
265
|
+
raw: dict[str, Any] = {
|
|
266
|
+
"base_url": resolved_url,
|
|
267
|
+
"model": resolved_model,
|
|
268
|
+
"api_key": resolved_key or "",
|
|
269
|
+
}
|
|
270
|
+
for name, flag in (
|
|
271
|
+
("temperature", temperature),
|
|
272
|
+
("timeout", timeout),
|
|
273
|
+
("retries", retries),
|
|
274
|
+
("max_tokens", max_tokens),
|
|
275
|
+
("seed", seed),
|
|
276
|
+
):
|
|
277
|
+
value = flag if flag is not None else pick(None, name.upper())
|
|
278
|
+
if value is not None:
|
|
279
|
+
raw[name] = value
|
|
280
|
+
|
|
281
|
+
if raw["api_key"] == "":
|
|
282
|
+
raise ConfigError("no API key: pass --api-key or set PRETEXT_API_KEY / OPENAI_API_KEY")
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
return Settings(**raw)
|
|
286
|
+
except ValidationError as exc:
|
|
287
|
+
raise ConfigError(_format_validation(exc, prefix="invalid settings")) from exc
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _format_validation(exc: ValidationError, *, prefix: str) -> str:
|
|
291
|
+
lines = [prefix]
|
|
292
|
+
for error in exc.errors():
|
|
293
|
+
location = ".".join(str(part) for part in error["loc"]) or "<root>"
|
|
294
|
+
lines.append(f" {location}: {error['msg']}")
|
|
295
|
+
return "\n".join(lines)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_profile(path: Path) -> Profile:
|
|
299
|
+
"""Read and validate a YAML profile."""
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
text = path.read_text(encoding="utf-8")
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
raise ProfileError(f"cannot read profile {path}: {exc.strerror or exc}") from exc
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
document = yaml.safe_load(text)
|
|
308
|
+
except yaml.YAMLError as exc:
|
|
309
|
+
raise ProfileError(f"invalid YAML in {path}: {exc}") from exc
|
|
310
|
+
|
|
311
|
+
if document is None:
|
|
312
|
+
raise ProfileError(f"profile {path} is empty")
|
|
313
|
+
if not isinstance(document, dict):
|
|
314
|
+
raise ProfileError(f"profile {path} must be a YAML mapping, got {type(document).__name__}")
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
return Profile.model_validate(document)
|
|
318
|
+
except ValidationError as exc:
|
|
319
|
+
raise ProfileError(_format_validation(exc, prefix=f"invalid profile {path}")) from exc
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def profile_from_json(payload: str) -> Profile:
|
|
323
|
+
try:
|
|
324
|
+
return Profile.model_validate_json(payload)
|
|
325
|
+
except ValidationError as exc:
|
|
326
|
+
raise ProfileError(_format_validation(exc, prefix="invalid profile JSON")) from exc
|
pretext/prompt.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Prompt construction for the simulation request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .models import Profile
|
|
8
|
+
|
|
9
|
+
SYSTEM_PROMPT = """\
|
|
10
|
+
You are a red-team instructor who writes social-engineering simulations used in \
|
|
11
|
+
authorized security-awareness training. Every simulation is clearly fictional but \
|
|
12
|
+
internally consistent: names, systems, and deadlines line up, and nothing reads \
|
|
13
|
+
like a generic template.
|
|
14
|
+
|
|
15
|
+
You return one JSON object and nothing else. No prose before or after, no code fence.
|
|
16
|
+
|
|
17
|
+
Schema:
|
|
18
|
+
{
|
|
19
|
+
"subject_line": string or null,
|
|
20
|
+
"objective": string,
|
|
21
|
+
"difficulty": integer 1-5,
|
|
22
|
+
"pretext": string,
|
|
23
|
+
"opening_message": string,
|
|
24
|
+
"escalation_path": [
|
|
25
|
+
{
|
|
26
|
+
"stage": integer starting at 1 and incrementing by 1,
|
|
27
|
+
"channel": "email" | "phone" | "sms" | "chat" | "in_person" | "linkedin",
|
|
28
|
+
"action": string,
|
|
29
|
+
"goal": string,
|
|
30
|
+
"artifact": string or null
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"red_flags": [
|
|
34
|
+
{"title": string, "why_it_matters": string, "defender_action": string}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Rules:
|
|
39
|
+
- The persona is the target. The attacker is a different character, named and
|
|
40
|
+
voiced; never let the attacker introduce themselves as the persona.
|
|
41
|
+
- Write attacker-side, operationally, in third person ("the attacker", "the
|
|
42
|
+
target"). The reader should be able to run the exercise from the text, not admire
|
|
43
|
+
the persona's feelings.
|
|
44
|
+
- "pretext": 150-350 words. Say who the attacker claims to be, which real detail
|
|
45
|
+
they harvested (a press release, a ticket, a directory listing, a conference
|
|
46
|
+
badge), what they registered or forged, and why contact lands today. Weave the
|
|
47
|
+
persona's role in as leverage, do not re-list the profile fields. Name concrete
|
|
48
|
+
artifacts: domains, ticket numbers, invoice references, calendar invites.
|
|
49
|
+
- "opening_message": the actual first message in the attacker's voice, usable
|
|
50
|
+
verbatim, including the sending address or the number displayed. Mirror the
|
|
51
|
+
communication style in sentence length and register, and carry at least one
|
|
52
|
+
artifact. For phone, write the spoken opening including the self-introduction.
|
|
53
|
+
Open on the ask, and name the sender by name and role in the first line on every
|
|
54
|
+
channel, not only phone; then the request, never a summary clause ("sent from X,
|
|
55
|
+
referencing Y") or past-tense narration of what the message does. No stage
|
|
56
|
+
directions, no "description of a message", and no "Subject:" or "From:" header
|
|
57
|
+
lines (the subject lives in "subject_line"); work the sending address or
|
|
58
|
+
the displayed number into the body text instead. Set "subject_line" to
|
|
59
|
+
something an insider would recognise, never "Urgent:" plus a topic.
|
|
60
|
+
- "escalation_path": 3-6 stages that follow the opening, at least 3 distinct
|
|
61
|
+
channels, never the same channel twice in a row. Stage 1 is the second contact
|
|
62
|
+
and must add a new prop, a new person, or a new demand; never restate the
|
|
63
|
+
opening's ask. Every
|
|
64
|
+
stage narrows what the target must do, hands over a concrete artifact, and reads
|
|
65
|
+
like the next escalation an operator would actually take. Escalate from low-friction to
|
|
66
|
+
high-commitment (a reply, then a callback, then a bank detail change).
|
|
67
|
+
- "red_flags": 3-5 tells tied to something specific in this exercise, each
|
|
68
|
+
phrased as observable evidence plus what to do about it. Include one that is
|
|
69
|
+
only obvious in hindsight. Never generic advice like "be careful with
|
|
70
|
+
attachments".
|
|
71
|
+
- "difficulty": 1 is untargeted volume phishing, 5 is bespoke and multi-day. Set
|
|
72
|
+
it from the research described, not from ambition.
|
|
73
|
+
- Use only fictional companies and domains; never reuse a real vendor's domain.
|
|
74
|
+
- The attacker mirrors the persona's trusted contacts, so match their register but
|
|
75
|
+
keep at least one stylistic seam for a defender to catch. Do not open with
|
|
76
|
+
"I hope this finds you well" or another stock greeting; the first line must
|
|
77
|
+
already sound like the sender from the profile.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_user_prompt(
|
|
82
|
+
profile: Profile, *, channel: str | None = None, extra: str | None = None
|
|
83
|
+
) -> str:
|
|
84
|
+
lines = ["Build a labeled simulation for this persona.", "", "Persona:"]
|
|
85
|
+
lines.append(f"- Name: {profile.name}")
|
|
86
|
+
lines.append(f"- Role: {profile.role}")
|
|
87
|
+
lines.append(f"- Company: {profile.company}")
|
|
88
|
+
lines.append(f"- Communication style: {profile.communication_style}")
|
|
89
|
+
for label, value in (
|
|
90
|
+
("Department", profile.department),
|
|
91
|
+
("Tenure", profile.tenure),
|
|
92
|
+
("Reports to", profile.reports_to),
|
|
93
|
+
("Notes", profile.notes),
|
|
94
|
+
):
|
|
95
|
+
if value:
|
|
96
|
+
lines.append(f"- {label}: {value}")
|
|
97
|
+
if profile.systems:
|
|
98
|
+
lines.append(f"- Systems access: {', '.join(profile.systems)}")
|
|
99
|
+
|
|
100
|
+
if channel:
|
|
101
|
+
lines += ["", f"Start the attack on the {channel} channel."]
|
|
102
|
+
if extra:
|
|
103
|
+
lines += ["", f"Additional direction: {extra.strip()}"]
|
|
104
|
+
|
|
105
|
+
lines += [
|
|
106
|
+
"",
|
|
107
|
+
"Write the pretext as the attacker's operation, not as a description of "
|
|
108
|
+
"the persona. Return only the JSON object.",
|
|
109
|
+
]
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_payload_messages(
|
|
114
|
+
profile: Profile, *, channel: str | None = None, extra: str | None = None
|
|
115
|
+
) -> list[dict[str, str]]:
|
|
116
|
+
return [
|
|
117
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
118
|
+
{"role": "user", "content": build_user_prompt(profile, channel=channel, extra=extra)},
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def dump_prompt(profile: Profile, **kwargs: object) -> str:
|
|
123
|
+
"""Human-readable prompt preview used by --dry-run."""
|
|
124
|
+
|
|
125
|
+
body = {"messages": build_payload_messages(profile, **kwargs)} # type: ignore[arg-type]
|
|
126
|
+
return json.dumps(body, indent=2, ensure_ascii=False)
|