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/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""pretext: labeled social-engineering simulations for authorized training."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .models import (
|
|
5
|
+
APIError,
|
|
6
|
+
ConfigError,
|
|
7
|
+
EscalationStep,
|
|
8
|
+
PretextError,
|
|
9
|
+
Profile,
|
|
10
|
+
ProfileError,
|
|
11
|
+
RedFlag,
|
|
12
|
+
Settings,
|
|
13
|
+
Simulation,
|
|
14
|
+
load_profile,
|
|
15
|
+
resolve_settings,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.3.2"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"APIError",
|
|
22
|
+
"Client",
|
|
23
|
+
"ConfigError",
|
|
24
|
+
"EscalationStep",
|
|
25
|
+
"PretextError",
|
|
26
|
+
"Profile",
|
|
27
|
+
"ProfileError",
|
|
28
|
+
"RedFlag",
|
|
29
|
+
"Settings",
|
|
30
|
+
"Simulation",
|
|
31
|
+
"__version__",
|
|
32
|
+
"load_profile",
|
|
33
|
+
"resolve_settings",
|
|
34
|
+
]
|
pretext/cli.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from . import __version__, render
|
|
16
|
+
from .client import Client, _content_of, extract_json
|
|
17
|
+
from .models import (
|
|
18
|
+
ENV_PREFIX,
|
|
19
|
+
PROVIDERS,
|
|
20
|
+
Channel,
|
|
21
|
+
ConfigError,
|
|
22
|
+
PretextError,
|
|
23
|
+
Profile,
|
|
24
|
+
ProfileError,
|
|
25
|
+
Simulation,
|
|
26
|
+
_format_validation,
|
|
27
|
+
load_profile,
|
|
28
|
+
profile_from_json,
|
|
29
|
+
resolve_settings,
|
|
30
|
+
)
|
|
31
|
+
from .prompt import dump_prompt
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
add_completion=False,
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
help="Generate labeled social-engineering simulations for authorized training.",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _version(value: bool) -> None:
|
|
41
|
+
if value:
|
|
42
|
+
typer.echo(f"pretext {__version__}")
|
|
43
|
+
raise typer.Exit()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def root(
|
|
48
|
+
version: bool = typer.Option(
|
|
49
|
+
False, "--version", "-V", is_eager=True, callback=_version, help="Show the version."
|
|
50
|
+
),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Labeled social-engineering simulations for authorized training."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OutputFormat(str, Enum):
|
|
56
|
+
rich = "rich"
|
|
57
|
+
json = "json"
|
|
58
|
+
markdown = "markdown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_profile(path: Path):
|
|
62
|
+
if str(path) == "-":
|
|
63
|
+
return profile_from_json(sys.stdin.read())
|
|
64
|
+
return load_profile(path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_response(path: Path) -> tuple[Profile | None, Simulation]:
|
|
68
|
+
"""Read a saved response: our own export, a bare simulation, or a chat body."""
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise ProfileError(f"cannot read response {path}: {exc.strerror or exc}") from exc
|
|
74
|
+
except json.JSONDecodeError as exc:
|
|
75
|
+
raise ProfileError(f"response {path} is not JSON: {exc}") from exc
|
|
76
|
+
|
|
77
|
+
if not isinstance(document, dict):
|
|
78
|
+
raise ProfileError(f"response {path} must be a JSON object")
|
|
79
|
+
|
|
80
|
+
profile = None
|
|
81
|
+
if isinstance(document.get("profile"), dict):
|
|
82
|
+
profile = Profile.model_validate(document["profile"])
|
|
83
|
+
|
|
84
|
+
raw = document.get("simulation", document)
|
|
85
|
+
if isinstance(document.get("choices"), list):
|
|
86
|
+
raw = extract_json(_content_of(document))
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
return profile, Simulation.model_validate(raw)
|
|
90
|
+
except ValidationError as exc:
|
|
91
|
+
raise ProfileError(_format_validation(exc, prefix=f"invalid response {path}")) from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _warnings(settings, profile=None) -> list[str]:
|
|
95
|
+
warnings: list[str] = []
|
|
96
|
+
if settings.base_url.scheme != "https":
|
|
97
|
+
warnings.append("endpoint is not HTTPS; the API key travels in clear text")
|
|
98
|
+
host = settings.base_url.host or ""
|
|
99
|
+
if host in {"localhost", "127.0.0.1"}:
|
|
100
|
+
warnings.append(f"endpoint is local ({host}); fine for a proxy or test server")
|
|
101
|
+
if settings.temperature > 1.2:
|
|
102
|
+
warnings.append("temperature above 1.2 often breaks the JSON contract")
|
|
103
|
+
if profile is not None and profile.company.lower() in {"acme", "example", "example corp"}:
|
|
104
|
+
warnings.append(f"company {profile.company!r} is generic; add specifics")
|
|
105
|
+
return warnings
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def generate(
|
|
110
|
+
profile: Path = typer.Argument(
|
|
111
|
+
None, metavar="[PROFILE]", help="YAML profile, or - to read JSON from stdin."
|
|
112
|
+
),
|
|
113
|
+
base_url: str = typer.Option(
|
|
114
|
+
None, "--base-url", "-u", help=f"API root [{ENV_PREFIX}BASE_URL]."
|
|
115
|
+
),
|
|
116
|
+
model: str = typer.Option(None, "--model", "-m", help=f"Model name [{ENV_PREFIX}MODEL]."),
|
|
117
|
+
api_key: str = typer.Option(
|
|
118
|
+
None, "--api-key", "-k", help=f"Bearer token [{ENV_PREFIX}API_KEY, OPENAI_API_KEY]."
|
|
119
|
+
),
|
|
120
|
+
provider: str = typer.Option(
|
|
121
|
+
None,
|
|
122
|
+
"--provider",
|
|
123
|
+
"-p",
|
|
124
|
+
help=f"Preset base URL: {', '.join(PROVIDERS)}. --base-url wins.",
|
|
125
|
+
),
|
|
126
|
+
channel: Channel | None = typer.Option(
|
|
127
|
+
None, "--channel", "-c", help="Force the opening channel."
|
|
128
|
+
),
|
|
129
|
+
temperature: float = typer.Option(
|
|
130
|
+
None, "--temperature", "-t", min=0.0, max=2.0, help="Sampling temperature."
|
|
131
|
+
),
|
|
132
|
+
max_tokens: int = typer.Option(None, "--max-tokens", min=256, max=16000),
|
|
133
|
+
timeout: float = typer.Option(None, "--timeout", min=1.0, max=600.0, help="Seconds."),
|
|
134
|
+
retries: int = typer.Option(None, "--retries", min=0, max=8, help="Retry count on 429/5xx."),
|
|
135
|
+
seed: int = typer.Option(None, "--seed", help="Sampling seed, if supported."),
|
|
136
|
+
extra: str = typer.Option(None, "--extra", help="Extra direction for the model."),
|
|
137
|
+
fmt: OutputFormat = typer.Option(OutputFormat.rich, "--format", "-f", help="Output format."),
|
|
138
|
+
out_file: Path = typer.Option(None, "--out", "-o", help="Write output to this file."),
|
|
139
|
+
dry_run: bool = typer.Option(
|
|
140
|
+
False,
|
|
141
|
+
"--dry-run",
|
|
142
|
+
help="Resolve config and print the prompt; call nothing. --out writes the prompt.",
|
|
143
|
+
),
|
|
144
|
+
response_file: Path = typer.Option(
|
|
145
|
+
None, "--from-file", help="Render a saved response instead of calling the API."
|
|
146
|
+
),
|
|
147
|
+
sections: str = typer.Option(
|
|
148
|
+
None, "--sections", help="Comma list of: pretext, opening, escalation, red_flags."
|
|
149
|
+
),
|
|
150
|
+
usage: bool = typer.Option(False, "--usage", help="Print token usage to stderr."),
|
|
151
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI color."),
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Generate one simulation from a profile."""
|
|
154
|
+
|
|
155
|
+
out = render.console(no_color=no_color)
|
|
156
|
+
err = render.console(no_color=no_color, stderr=True)
|
|
157
|
+
|
|
158
|
+
selected: tuple[str, ...] = render.SECTIONS
|
|
159
|
+
if sections is not None:
|
|
160
|
+
selected = tuple(part.strip() for part in sections.split(",") if part.strip())
|
|
161
|
+
unknown = [part for part in selected if part not in render.SECTIONS]
|
|
162
|
+
if unknown or not selected:
|
|
163
|
+
err.print(
|
|
164
|
+
f"[bold red]error[/bold red] unknown section(s): {', '.join(unknown) or 'none'}; "
|
|
165
|
+
f"choose from {', '.join(render.SECTIONS)}"
|
|
166
|
+
)
|
|
167
|
+
raise typer.Exit(2)
|
|
168
|
+
|
|
169
|
+
if profile is None and response_file is None:
|
|
170
|
+
err.print("[bold red]error[/bold red] pass a PROFILE or --from-file")
|
|
171
|
+
raise typer.Exit(2)
|
|
172
|
+
|
|
173
|
+
saved = None
|
|
174
|
+
try:
|
|
175
|
+
if response_file is not None:
|
|
176
|
+
saved_profile, saved = _read_response(response_file)
|
|
177
|
+
if profile is None:
|
|
178
|
+
if saved_profile is None:
|
|
179
|
+
raise ProfileError(
|
|
180
|
+
f"response {response_file} has no profile; pass a PROFILE too"
|
|
181
|
+
)
|
|
182
|
+
prof = saved_profile
|
|
183
|
+
if profile is not None:
|
|
184
|
+
prof = _read_profile(profile)
|
|
185
|
+
settings = resolve_settings(
|
|
186
|
+
provider=provider,
|
|
187
|
+
base_url=base_url,
|
|
188
|
+
model=model,
|
|
189
|
+
api_key=api_key or ("unused" if saved is not None else None),
|
|
190
|
+
temperature=temperature,
|
|
191
|
+
timeout=timeout,
|
|
192
|
+
retries=retries,
|
|
193
|
+
max_tokens=max_tokens,
|
|
194
|
+
seed=seed,
|
|
195
|
+
dotenv=Path.cwd() / ".env",
|
|
196
|
+
)
|
|
197
|
+
except PretextError as exc:
|
|
198
|
+
err.print(f"[bold red]error[/bold red] {exc}")
|
|
199
|
+
raise typer.Exit(1) from exc
|
|
200
|
+
|
|
201
|
+
warnings = _warnings(settings, prof)
|
|
202
|
+
|
|
203
|
+
if dry_run:
|
|
204
|
+
err.print("[bold]dry run[/bold] · nothing is sent")
|
|
205
|
+
render.render_settings(settings, err)
|
|
206
|
+
render.render_profile(prof, err)
|
|
207
|
+
render.render_warnings(warnings, err)
|
|
208
|
+
prompt = dump_prompt(prof, channel=channel.value if channel else None, extra=extra)
|
|
209
|
+
if out_file is None:
|
|
210
|
+
err.print()
|
|
211
|
+
typer.echo(prompt)
|
|
212
|
+
else:
|
|
213
|
+
out_file.write_text(prompt + "\n", encoding="utf-8")
|
|
214
|
+
err.print(f"wrote prompt to {out_file}")
|
|
215
|
+
raise typer.Exit(0)
|
|
216
|
+
|
|
217
|
+
render.render_warnings(warnings, err)
|
|
218
|
+
|
|
219
|
+
client = Client(settings)
|
|
220
|
+
if saved is not None:
|
|
221
|
+
simulation = saved
|
|
222
|
+
err.print(f"[dim]replayed {response_file}[/dim]")
|
|
223
|
+
else:
|
|
224
|
+
try:
|
|
225
|
+
with err.status("[cyan]requesting simulation…[/cyan]", spinner="dots"):
|
|
226
|
+
simulation = client.generate(
|
|
227
|
+
prof, channel=channel.value if channel else None, extra=extra
|
|
228
|
+
)
|
|
229
|
+
except PretextError as exc:
|
|
230
|
+
err.print(f"[bold red]error[/bold red] {exc}")
|
|
231
|
+
raise typer.Exit(1) from exc
|
|
232
|
+
except KeyboardInterrupt:
|
|
233
|
+
err.print("[yellow]interrupted[/yellow]")
|
|
234
|
+
raise typer.Exit(130) from None
|
|
235
|
+
|
|
236
|
+
if usage:
|
|
237
|
+
tokens = client.last_usage or {}
|
|
238
|
+
err.print(
|
|
239
|
+
f"[dim]tokens: prompt {tokens.get('prompt_tokens', '?')} · "
|
|
240
|
+
f"completion {tokens.get('completion_tokens', '?')} · "
|
|
241
|
+
f"total {tokens.get('total_tokens', '?')}[/dim]"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if fmt is OutputFormat.json:
|
|
245
|
+
body = render.to_json(simulation, prof, sections=selected)
|
|
246
|
+
elif fmt is OutputFormat.markdown:
|
|
247
|
+
body = render.to_markdown(simulation, prof, sections=selected)
|
|
248
|
+
else:
|
|
249
|
+
body = None
|
|
250
|
+
|
|
251
|
+
if out_file is not None:
|
|
252
|
+
if body is None:
|
|
253
|
+
recording = render.console(no_color=True, width=100)
|
|
254
|
+
with recording.capture() as captured:
|
|
255
|
+
render.render_simulation(simulation, prof, recording, sections=selected)
|
|
256
|
+
body = captured.get()
|
|
257
|
+
out_file.write_text(body, encoding="utf-8")
|
|
258
|
+
err.print(f"wrote {out_file}")
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if body is not None:
|
|
262
|
+
typer.echo(body)
|
|
263
|
+
else:
|
|
264
|
+
render.render_simulation(simulation, prof, out, sections=selected)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.command()
|
|
268
|
+
def validate(profile: Path = typer.Argument(..., metavar="PROFILE")) -> None:
|
|
269
|
+
"""Check a profile without calling the API."""
|
|
270
|
+
|
|
271
|
+
out = render.console()
|
|
272
|
+
try:
|
|
273
|
+
prof = _read_profile(profile)
|
|
274
|
+
except PretextError as exc:
|
|
275
|
+
render.console(stderr=True).print(f"[bold red]invalid[/bold red] {exc}")
|
|
276
|
+
raise typer.Exit(1) from exc
|
|
277
|
+
render.render_profile(prof, out)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.command()
|
|
281
|
+
def example(
|
|
282
|
+
out_file: Path = typer.Option(None, "--out", "-o", help="Write instead of printing."),
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Print the bundled example profile."""
|
|
285
|
+
|
|
286
|
+
text = (
|
|
287
|
+
resources.files("pretext").joinpath("data/example-profile.yaml").read_text(encoding="utf-8")
|
|
288
|
+
)
|
|
289
|
+
if out_file is None:
|
|
290
|
+
typer.echo(text, nl=False)
|
|
291
|
+
else:
|
|
292
|
+
out_file.write_text(text, encoding="utf-8")
|
|
293
|
+
typer.echo(f"wrote {out_file}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.command("config")
|
|
297
|
+
def show_config(
|
|
298
|
+
provider: str = typer.Option(None, "--provider", "-p", help=f"Preset: {', '.join(PROVIDERS)}."),
|
|
299
|
+
base_url: str = typer.Option(None, "--base-url", "-u"),
|
|
300
|
+
model: str = typer.Option(None, "--model", "-m"),
|
|
301
|
+
api_key: str = typer.Option(None, "--api-key", "-k"),
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Show the configuration that would be used, with the key masked."""
|
|
304
|
+
|
|
305
|
+
out = render.console()
|
|
306
|
+
err = render.console(stderr=True)
|
|
307
|
+
try:
|
|
308
|
+
settings = resolve_settings(
|
|
309
|
+
provider=provider,
|
|
310
|
+
base_url=base_url,
|
|
311
|
+
model=model,
|
|
312
|
+
api_key=api_key,
|
|
313
|
+
dotenv=Path.cwd() / ".env",
|
|
314
|
+
)
|
|
315
|
+
except ConfigError as exc:
|
|
316
|
+
err.print(f"[bold red]error[/bold red] {exc}")
|
|
317
|
+
raise typer.Exit(1) from exc
|
|
318
|
+
render.render_settings(settings, out)
|
|
319
|
+
render.render_warnings(_warnings(settings), err)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main() -> None:
|
|
323
|
+
app()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
main()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@app.command()
|
|
331
|
+
def batch(
|
|
332
|
+
directory: Path = typer.Argument(..., metavar="DIR", help="Directory of YAML profiles."),
|
|
333
|
+
out_dir: Path = typer.Option(..., "--out-dir", "-o", help="Where to write each exercise."),
|
|
334
|
+
fmt: OutputFormat = typer.Option(OutputFormat.markdown, "--format", "-f"),
|
|
335
|
+
sections: str = typer.Option(None, "--sections", help="Comma list of sections to render."),
|
|
336
|
+
extra: str = typer.Option(None, "--extra", help="Extra direction for the model."),
|
|
337
|
+
base_url: str = typer.Option(
|
|
338
|
+
None, "--base-url", "-u", help=f"API root [{ENV_PREFIX}BASE_URL]."
|
|
339
|
+
),
|
|
340
|
+
model: str = typer.Option(None, "--model", "-m", help=f"Model name [{ENV_PREFIX}MODEL]."),
|
|
341
|
+
api_key: str = typer.Option(
|
|
342
|
+
None, "--api-key", "-k", help=f"Bearer token [{ENV_PREFIX}API_KEY, OPENAI_API_KEY]."
|
|
343
|
+
),
|
|
344
|
+
provider: str = typer.Option(
|
|
345
|
+
None,
|
|
346
|
+
"--provider",
|
|
347
|
+
"-p",
|
|
348
|
+
help=f"Preset base URL: {', '.join(PROVIDERS)}. --base-url wins.",
|
|
349
|
+
),
|
|
350
|
+
temperature: float = typer.Option(None, "--temperature", "-t", min=0.0, max=2.0),
|
|
351
|
+
max_tokens: int = typer.Option(None, "--max-tokens", min=256, max=16000),
|
|
352
|
+
timeout: float = typer.Option(None, "--timeout", min=1.0, max=600.0),
|
|
353
|
+
retries: int = typer.Option(None, "--retries", min=0, max=8),
|
|
354
|
+
seed: int = typer.Option(None, "--seed"),
|
|
355
|
+
usage: bool = typer.Option(False, "--usage", help="Print token totals to stderr."),
|
|
356
|
+
no_color: bool = typer.Option(False, "--no-color"),
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Generate one exercise per profile in a directory."""
|
|
359
|
+
|
|
360
|
+
err = render.console(no_color=no_color, stderr=True)
|
|
361
|
+
selected: tuple[str, ...] = render.SECTIONS
|
|
362
|
+
if sections is not None:
|
|
363
|
+
selected = tuple(part.strip() for part in sections.split(",") if part.strip())
|
|
364
|
+
unknown = [part for part in selected if part not in render.SECTIONS]
|
|
365
|
+
if unknown or not selected:
|
|
366
|
+
err.print(f"[bold red]error[/bold red] unknown section(s): {', '.join(unknown)}")
|
|
367
|
+
raise typer.Exit(2)
|
|
368
|
+
|
|
369
|
+
if not directory.is_dir():
|
|
370
|
+
err.print(f"[bold red]error[/bold red] {directory} is not a directory")
|
|
371
|
+
raise typer.Exit(1)
|
|
372
|
+
|
|
373
|
+
profiles = sorted(p for p in directory.glob("*.yaml") if not p.name.startswith("_"))
|
|
374
|
+
if not profiles:
|
|
375
|
+
err.print(f"[bold red]error[/bold red] no *.yaml profiles in {directory}")
|
|
376
|
+
raise typer.Exit(1)
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
settings = resolve_settings(
|
|
380
|
+
provider=provider,
|
|
381
|
+
base_url=base_url,
|
|
382
|
+
model=model,
|
|
383
|
+
api_key=api_key,
|
|
384
|
+
temperature=temperature,
|
|
385
|
+
timeout=timeout,
|
|
386
|
+
retries=retries,
|
|
387
|
+
max_tokens=max_tokens,
|
|
388
|
+
seed=seed,
|
|
389
|
+
dotenv=Path.cwd() / ".env",
|
|
390
|
+
)
|
|
391
|
+
except PretextError as exc:
|
|
392
|
+
err.print(f"[bold red]error[/bold red] {exc}")
|
|
393
|
+
raise typer.Exit(1) from exc
|
|
394
|
+
|
|
395
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
client = Client(settings)
|
|
397
|
+
suffix = {"markdown": "md", "json": "json", "rich": "txt"}[fmt.value]
|
|
398
|
+
failures: list[tuple[str, str]] = []
|
|
399
|
+
totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
400
|
+
|
|
401
|
+
for index, path in enumerate(profiles, start=1):
|
|
402
|
+
label = f"[cyan]{index}/{len(profiles)}[/cyan] {path.name}"
|
|
403
|
+
try:
|
|
404
|
+
prof = load_profile(path)
|
|
405
|
+
simulation = client.generate(prof, channel=None, extra=extra)
|
|
406
|
+
except PretextError as exc:
|
|
407
|
+
failures.append((path.name, str(exc).splitlines()[0]))
|
|
408
|
+
err.print(f"{label} [red]failed[/red] {failures[-1][1]}")
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
for key in totals:
|
|
412
|
+
totals[key] += (client.last_usage or {}).get(key, 0)
|
|
413
|
+
|
|
414
|
+
target = out_dir / f"{path.stem}.{suffix}"
|
|
415
|
+
if fmt is OutputFormat.json:
|
|
416
|
+
body = render.to_json(simulation, prof, sections=selected)
|
|
417
|
+
elif fmt is OutputFormat.markdown:
|
|
418
|
+
body = render.to_markdown(simulation, prof, sections=selected)
|
|
419
|
+
else:
|
|
420
|
+
recording = render.console(no_color=True, width=100)
|
|
421
|
+
with recording.capture() as captured:
|
|
422
|
+
render.render_simulation(simulation, prof, recording, sections=selected)
|
|
423
|
+
body = captured.get()
|
|
424
|
+
target.write_text(body, encoding="utf-8")
|
|
425
|
+
err.print(f"{label} [green]ok[/green] {target.name}")
|
|
426
|
+
|
|
427
|
+
err.print()
|
|
428
|
+
err.print(f"wrote {len(profiles) - len(failures)}/{len(profiles)} to {out_dir}")
|
|
429
|
+
if usage:
|
|
430
|
+
err.print(
|
|
431
|
+
f"[dim]tokens: prompt {totals['prompt_tokens']} · "
|
|
432
|
+
f"completion {totals['completion_tokens']} · total {totals['total_tokens']}[/dim]"
|
|
433
|
+
)
|
|
434
|
+
for name, reason in failures:
|
|
435
|
+
err.print(f"[red]{name}[/red] {reason}")
|
|
436
|
+
if failures:
|
|
437
|
+
raise typer.Exit(1)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@app.command()
|
|
441
|
+
def doctor(
|
|
442
|
+
base_url: str = typer.Option(
|
|
443
|
+
None, "--base-url", "-u", help=f"API root [{ENV_PREFIX}BASE_URL]."
|
|
444
|
+
),
|
|
445
|
+
model: str = typer.Option(None, "--model", "-m", help=f"Model name [{ENV_PREFIX}MODEL]."),
|
|
446
|
+
api_key: str = typer.Option(
|
|
447
|
+
None, "--api-key", "-k", help=f"Bearer token [{ENV_PREFIX}API_KEY, OPENAI_API_KEY]."
|
|
448
|
+
),
|
|
449
|
+
provider: str = typer.Option(
|
|
450
|
+
None,
|
|
451
|
+
"--provider",
|
|
452
|
+
"-p",
|
|
453
|
+
help=f"Preset base URL: {', '.join(PROVIDERS)}. --base-url wins.",
|
|
454
|
+
),
|
|
455
|
+
full: bool = typer.Option(False, "--full", help="Also run one real simulation."),
|
|
456
|
+
no_color: bool = typer.Option(False, "--no-color"),
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Diagnose the endpoint before spending a run on it."""
|
|
459
|
+
|
|
460
|
+
out = render.console(no_color=no_color)
|
|
461
|
+
err = render.console(no_color=no_color, stderr=True)
|
|
462
|
+
results: list[tuple[str, str, str]] = []
|
|
463
|
+
|
|
464
|
+
def record(name: str, status: str, detail: str) -> None:
|
|
465
|
+
results.append((name, status, detail))
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
settings = resolve_settings(
|
|
469
|
+
provider=provider,
|
|
470
|
+
base_url=base_url,
|
|
471
|
+
model=model,
|
|
472
|
+
api_key=api_key,
|
|
473
|
+
dotenv=Path.cwd() / ".env",
|
|
474
|
+
)
|
|
475
|
+
record("configuration", "pass", f"{settings.base_url} · {settings.model}")
|
|
476
|
+
except PretextError as exc:
|
|
477
|
+
record("configuration", "fail", str(exc))
|
|
478
|
+
_render_checks(results, out)
|
|
479
|
+
raise typer.Exit(1) from exc
|
|
480
|
+
|
|
481
|
+
if settings.base_url.scheme != "https":
|
|
482
|
+
record("tls", "warn", "not HTTPS; the key is sent in clear text")
|
|
483
|
+
else:
|
|
484
|
+
record("tls", "pass", "HTTPS")
|
|
485
|
+
|
|
486
|
+
client = Client(settings)
|
|
487
|
+
try:
|
|
488
|
+
models = client.list_models()
|
|
489
|
+
record("endpoint", "pass", "reached /models")
|
|
490
|
+
if not models:
|
|
491
|
+
record("model", "warn", "endpoint lists no models; cannot verify the name")
|
|
492
|
+
elif settings.model in models:
|
|
493
|
+
record("model", "pass", f"{settings.model} is served")
|
|
494
|
+
else:
|
|
495
|
+
near = [m for m in models if m.split("/")[-1] in settings.model.split("/")[-1]][:3]
|
|
496
|
+
hint = f"; did you mean {', '.join(near)}" if near else ""
|
|
497
|
+
record("model", "fail", f"{settings.model} is not in the model list{hint}")
|
|
498
|
+
except PretextError as exc:
|
|
499
|
+
record("endpoint", "fail", str(exc))
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
parsed = extract_json(client.ping_json())
|
|
503
|
+
record("json mode", "pass", f"returned {', '.join(sorted(parsed)) or 'an empty object'}")
|
|
504
|
+
except PretextError as exc:
|
|
505
|
+
record("json mode", "fail", str(exc))
|
|
506
|
+
|
|
507
|
+
if full:
|
|
508
|
+
probe = Profile(
|
|
509
|
+
name="Test Persona",
|
|
510
|
+
role="Analyst",
|
|
511
|
+
company="Northwind",
|
|
512
|
+
communication_style="Plain and brief",
|
|
513
|
+
)
|
|
514
|
+
try:
|
|
515
|
+
simulation = client.generate(probe)
|
|
516
|
+
record(
|
|
517
|
+
"schema",
|
|
518
|
+
"pass",
|
|
519
|
+
f"{len(simulation.escalation_path)} stages, {len(simulation.red_flags)} flags",
|
|
520
|
+
)
|
|
521
|
+
except PretextError as exc:
|
|
522
|
+
record("schema", "fail", str(exc).splitlines()[0])
|
|
523
|
+
|
|
524
|
+
_render_checks(results, out)
|
|
525
|
+
if any(status == "fail" for _, status, _ in results):
|
|
526
|
+
raise typer.Exit(1)
|
|
527
|
+
if not full:
|
|
528
|
+
err.print("[dim]add --full to test schema adherence too[/dim]")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _render_checks(results: list[tuple[str, str, str]], out) -> None:
|
|
532
|
+
marks = {
|
|
533
|
+
"pass": "[green]pass[/green]",
|
|
534
|
+
"warn": "[yellow]warn[/yellow]",
|
|
535
|
+
"fail": "[red]fail[/red]",
|
|
536
|
+
}
|
|
537
|
+
table = Table(show_header=True, box=None, header_style="bold")
|
|
538
|
+
table.add_column("check")
|
|
539
|
+
table.add_column("")
|
|
540
|
+
table.add_column("detail", overflow="fold")
|
|
541
|
+
for name, status, detail in results:
|
|
542
|
+
table.add_row(name, marks[status], detail)
|
|
543
|
+
out.print(table)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@app.command()
|
|
547
|
+
def schema(
|
|
548
|
+
everything: bool = typer.Option(False, "--all", help="Include the profile schema too."),
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Print the JSON Schema the model output must satisfy."""
|
|
551
|
+
|
|
552
|
+
payload = {"simulation": Simulation.model_json_schema()}
|
|
553
|
+
if everything:
|
|
554
|
+
payload["profile"] = Profile.model_json_schema()
|
|
555
|
+
typer.echo(json.dumps(payload, indent=2))
|