draftomen 0.3.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.
- draftomen/__init__.py +17 -0
- draftomen/assets/draftomen.icns +0 -0
- draftomen/assets/draftomen.ico +0 -0
- draftomen/assets/draftomen_logo.png +0 -0
- draftomen/audit.py +606 -0
- draftomen/backtest.py +449 -0
- draftomen/benchmark.py +834 -0
- draftomen/carddb.py +1869 -0
- draftomen/cardimages.py +313 -0
- draftomen/cli.py +891 -0
- draftomen/config.py +133 -0
- draftomen/deckbuilder.py +2723 -0
- draftomen/events.py +772 -0
- draftomen/logfollow.py +451 -0
- draftomen/mock_session.py +745 -0
- draftomen/paths.py +120 -0
- draftomen/pickengine.py +1117 -0
- draftomen/pool.py +1259 -0
- draftomen/preferences.py +289 -0
- draftomen/qml/AboutDialog.qml +156 -0
- draftomen/qml/AppBar.qml +120 -0
- draftomen/qml/BacktestView.qml +316 -0
- draftomen/qml/BuildView.qml +1151 -0
- draftomen/qml/CardPreview.qml +434 -0
- draftomen/qml/DimensionalButton.qml +42 -0
- draftomen/qml/DimensionalComboBox.qml +182 -0
- draftomen/qml/DimensionalSurface.qml +118 -0
- draftomen/qml/DimensionalTabButton.qml +41 -0
- draftomen/qml/LiveDraftView.qml +468 -0
- draftomen/qml/Main.qml +175 -0
- draftomen/qml/NavigationRail.qml +226 -0
- draftomen/qml/PoolSummaryPanel.qml +322 -0
- draftomen/qml/PrivacyDialog.qml +96 -0
- draftomen/qml/RecentPickThumbnail.qml +83 -0
- draftomen/qml/RecentPicksGallery.qml +333 -0
- draftomen/qml/RecommendationRow.qml +404 -0
- draftomen/qml/SettingsSwitch.qml +141 -0
- draftomen/qml/SettingsView.qml +531 -0
- draftomen/qml/StateBanner.qml +189 -0
- draftomen/qml/StatusStrip.qml +84 -0
- draftomen/qml/Theme.qml +71 -0
- draftomen/qml/qmldir +23 -0
- draftomen/qt_adapter.py +884 -0
- draftomen/qt_gui.py +338 -0
- draftomen/qt_mock.py +63 -0
- draftomen/ranking.py +126 -0
- draftomen/replay.py +480 -0
- draftomen/session.py +3668 -0
- draftomen/setinfo.py +26 -0
- draftomen/seventeen.py +2766 -0
- draftomen/splash.py +617 -0
- draftomen/tui.py +4007 -0
- draftomen/watch.py +336 -0
- draftomen-0.3.0.dist-info/METADATA +156 -0
- draftomen-0.3.0.dist-info/RECORD +59 -0
- draftomen-0.3.0.dist-info/WHEEL +5 -0
- draftomen-0.3.0.dist-info/entry_points.txt +6 -0
- draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
- draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/backtest.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Post-draft recommendation backtesting.
|
|
2
|
+
Replay saved picks without mutating draft state.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from os import PathLike
|
|
9
|
+
from typing import TypeAlias
|
|
10
|
+
|
|
11
|
+
from draftomen.carddb import CardDatabase, CardInfo
|
|
12
|
+
from draftomen.events import EXPECTED_PICKS_PER_PACK
|
|
13
|
+
from draftomen.pickengine import PickEngine, ScoredCard
|
|
14
|
+
from draftomen.pool import DraftPick, DraftState, list_draft_states
|
|
15
|
+
from draftomen.ranking import DEFAULT_RANKING_MODE, rank_scored_cards, ranking_label
|
|
16
|
+
from draftomen.replay import format_card_info
|
|
17
|
+
from draftomen.seventeen import SeventeenLandsData
|
|
18
|
+
|
|
19
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BacktestError(RuntimeError):
|
|
23
|
+
"""Raised when a persisted draft cannot be backtested.
|
|
24
|
+
CLI callers surface this as a concise diagnostic.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class BacktestPickResult:
|
|
30
|
+
"""Recommendation comparison for one saved draft pick.
|
|
31
|
+
Missing history keeps a skipped row instead of mutating state.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
pack_number: int
|
|
35
|
+
pick_number: int
|
|
36
|
+
pool_size: int | None
|
|
37
|
+
offered_count: int | None
|
|
38
|
+
recommended: ScoredCard | None
|
|
39
|
+
actual: CardInfo | None
|
|
40
|
+
match: bool | None
|
|
41
|
+
skipped_reason: str | None
|
|
42
|
+
data_source: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class BacktestReport:
|
|
47
|
+
"""Backtest rows plus the persisted draft metadata.
|
|
48
|
+
Derived counts keep formatting and tests simple.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
state: DraftState
|
|
52
|
+
rows: tuple[BacktestPickResult, ...]
|
|
53
|
+
ranking_mode: str
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def compared_rows(self) -> tuple[BacktestPickResult, ...]:
|
|
57
|
+
"""Return rows where recommendation and actual pick can be compared.
|
|
58
|
+
Rows skipped for missing history are excluded.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
return tuple(row for row in self.rows if row.match is not None)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def skipped_rows(self) -> tuple[BacktestPickResult, ...]:
|
|
65
|
+
"""Return rows that could not produce a comparison.
|
|
66
|
+
The row itself carries the user-facing reason.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
return tuple(row for row in self.rows if row.match is None)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def match_count(self) -> int:
|
|
73
|
+
"""Return how many comparable picks matched Draftomen's recommendation.
|
|
74
|
+
A skipped pick is not counted as a miss.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
return sum(1 for row in self.rows if row.match is True)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def data_sources(self) -> tuple[str, ...]:
|
|
81
|
+
"""Return unique pack data-source summaries in first-seen order.
|
|
82
|
+
Missing-history rows do not contribute a source label.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
sources: list[str] = []
|
|
86
|
+
for row in self.rows:
|
|
87
|
+
if row.data_source is None or row.data_source in sources:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
sources.append(row.data_source)
|
|
91
|
+
|
|
92
|
+
return tuple(sources)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_persisted_backtest_state(
|
|
96
|
+
*,
|
|
97
|
+
app_dir: PathInput | None = None,
|
|
98
|
+
account_id: str | None = None,
|
|
99
|
+
draft_id: str | None = None,
|
|
100
|
+
) -> DraftState:
|
|
101
|
+
"""Load the requested persisted draft state, defaulting to latest.
|
|
102
|
+
Account and draft filters disambiguate local multi-account state.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
matches = _matching_states(
|
|
106
|
+
states=list_draft_states(app_dir=app_dir),
|
|
107
|
+
account_id=account_id,
|
|
108
|
+
draft_id=draft_id,
|
|
109
|
+
)
|
|
110
|
+
if not matches:
|
|
111
|
+
raise BacktestError(
|
|
112
|
+
_missing_persisted_draft_message(account_id=account_id, draft_id=draft_id)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if draft_id is not None and len(matches) > 1:
|
|
116
|
+
raise BacktestError(
|
|
117
|
+
f"Multiple persisted drafts use draft id {draft_id!r}; pass --account."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return _latest_state(states=matches)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_backtest_report(
|
|
124
|
+
*,
|
|
125
|
+
state: DraftState,
|
|
126
|
+
card_database: CardDatabase,
|
|
127
|
+
ratings_data: SeventeenLandsData | None = None,
|
|
128
|
+
pick_engine: PickEngine | None = None,
|
|
129
|
+
ranking_mode: str = DEFAULT_RANKING_MODE,
|
|
130
|
+
splash_enabled: bool = True,
|
|
131
|
+
) -> BacktestReport:
|
|
132
|
+
"""Score each saved pick from the persisted pre-pick state.
|
|
133
|
+
The function is read-only and never writes draft state.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
engine = (
|
|
137
|
+
pick_engine
|
|
138
|
+
if pick_engine is not None
|
|
139
|
+
else PickEngine(
|
|
140
|
+
ratings_data=ratings_data,
|
|
141
|
+
splash_enabled=splash_enabled,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
rows = tuple(
|
|
145
|
+
_score_pick(
|
|
146
|
+
pick=pick,
|
|
147
|
+
card_database=card_database,
|
|
148
|
+
pick_engine=engine,
|
|
149
|
+
ranking_mode=ranking_mode,
|
|
150
|
+
)
|
|
151
|
+
for pick in state.picks
|
|
152
|
+
)
|
|
153
|
+
return BacktestReport(state=state, rows=rows, ranking_mode=ranking_mode)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def format_backtest_report(report: BacktestReport) -> str:
|
|
157
|
+
"""Format a post-draft recommendation comparison report.
|
|
158
|
+
The output is stable plain text for CLI use and regression tests.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
lines = _format_header(report=report)
|
|
162
|
+
lines.append("")
|
|
163
|
+
lines.extend(_format_rows(report=report))
|
|
164
|
+
lines.append("")
|
|
165
|
+
lines.extend(_format_summary(report=report))
|
|
166
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _score_pick(
|
|
170
|
+
*,
|
|
171
|
+
pick: DraftPick,
|
|
172
|
+
card_database: CardDatabase,
|
|
173
|
+
pick_engine: PickEngine,
|
|
174
|
+
ranking_mode: str,
|
|
175
|
+
) -> BacktestPickResult:
|
|
176
|
+
actual = (
|
|
177
|
+
card_database.lookup(grp_id=pick.chosen_grp_id)
|
|
178
|
+
if pick.chosen_grp_id is not None
|
|
179
|
+
else None
|
|
180
|
+
)
|
|
181
|
+
if pick.offered_grp_ids is None:
|
|
182
|
+
return _skipped_result(
|
|
183
|
+
pick=pick,
|
|
184
|
+
actual=actual,
|
|
185
|
+
reason="missing offered-card history",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not pick.offered_grp_ids:
|
|
189
|
+
return _skipped_result(
|
|
190
|
+
pick=pick,
|
|
191
|
+
actual=actual,
|
|
192
|
+
reason="empty offered-card history",
|
|
193
|
+
offered_count=0,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if pick.pool_before_pick is None:
|
|
197
|
+
return _skipped_result(
|
|
198
|
+
pick=pick,
|
|
199
|
+
actual=actual,
|
|
200
|
+
reason="missing pool-before-pick snapshot",
|
|
201
|
+
offered_count=len(pick.offered_grp_ids),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
scored_pack = pick_engine.score_pack(
|
|
205
|
+
offered_grp_ids=pick.offered_grp_ids,
|
|
206
|
+
card_database=card_database,
|
|
207
|
+
pool_grp_ids=pick.pool_before_pick,
|
|
208
|
+
pick_index=_draft_pick_index(pick=pick),
|
|
209
|
+
)
|
|
210
|
+
ranked_cards = rank_scored_cards(
|
|
211
|
+
cards=scored_pack.cards,
|
|
212
|
+
ranking_mode=ranking_mode,
|
|
213
|
+
)
|
|
214
|
+
recommended = ranked_cards[0] if ranked_cards else None
|
|
215
|
+
if recommended is None:
|
|
216
|
+
return _skipped_result(
|
|
217
|
+
pick=pick,
|
|
218
|
+
actual=actual,
|
|
219
|
+
reason="no recommended card",
|
|
220
|
+
pool_size=len(pick.pool_before_pick),
|
|
221
|
+
offered_count=len(pick.offered_grp_ids),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if pick.chosen_grp_id is None:
|
|
225
|
+
return BacktestPickResult(
|
|
226
|
+
pack_number=pick.pack_number,
|
|
227
|
+
pick_number=pick.pick_number,
|
|
228
|
+
pool_size=len(pick.pool_before_pick),
|
|
229
|
+
offered_count=len(pick.offered_grp_ids),
|
|
230
|
+
recommended=recommended,
|
|
231
|
+
actual=None,
|
|
232
|
+
match=None,
|
|
233
|
+
skipped_reason="missing actual selected card",
|
|
234
|
+
data_source=scored_pack.source_summary,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return BacktestPickResult(
|
|
238
|
+
pack_number=pick.pack_number,
|
|
239
|
+
pick_number=pick.pick_number,
|
|
240
|
+
pool_size=len(pick.pool_before_pick),
|
|
241
|
+
offered_count=len(pick.offered_grp_ids),
|
|
242
|
+
recommended=recommended,
|
|
243
|
+
actual=actual,
|
|
244
|
+
match=recommended.card.grp_id == pick.chosen_grp_id,
|
|
245
|
+
skipped_reason=None,
|
|
246
|
+
data_source=scored_pack.source_summary,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _skipped_result(
|
|
251
|
+
*,
|
|
252
|
+
pick: DraftPick,
|
|
253
|
+
actual: CardInfo | None,
|
|
254
|
+
reason: str,
|
|
255
|
+
pool_size: int | None = None,
|
|
256
|
+
offered_count: int | None = None,
|
|
257
|
+
) -> BacktestPickResult:
|
|
258
|
+
return BacktestPickResult(
|
|
259
|
+
pack_number=pick.pack_number,
|
|
260
|
+
pick_number=pick.pick_number,
|
|
261
|
+
pool_size=pool_size,
|
|
262
|
+
offered_count=offered_count,
|
|
263
|
+
recommended=None,
|
|
264
|
+
actual=actual,
|
|
265
|
+
match=None,
|
|
266
|
+
skipped_reason=reason,
|
|
267
|
+
data_source=None,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _draft_pick_index(*, pick: DraftPick) -> int:
|
|
272
|
+
return (pick.pack_number * EXPECTED_PICKS_PER_PACK) + pick.pick_number + 1
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _format_header(*, report: BacktestReport) -> list[str]:
|
|
276
|
+
state = report.state
|
|
277
|
+
return [
|
|
278
|
+
"Draft Omen backtest",
|
|
279
|
+
f"Account: {_format_account(state=state)}",
|
|
280
|
+
f"Set: {state.set_code}",
|
|
281
|
+
f"Event: {state.event_name}",
|
|
282
|
+
f"Draft: {state.draft_id}",
|
|
283
|
+
f"Completed: {_yes_no(state.completed)}",
|
|
284
|
+
f"Ranking: {ranking_label(ranking_mode=report.ranking_mode)}",
|
|
285
|
+
(
|
|
286
|
+
"Picks: "
|
|
287
|
+
f"{state.chosen_pick_count} chosen, "
|
|
288
|
+
f"{len(report.compared_rows)} compared, "
|
|
289
|
+
f"{len(report.skipped_rows)} skipped"
|
|
290
|
+
),
|
|
291
|
+
f"Data sources: {_format_data_sources(report=report)}",
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _format_rows(*, report: BacktestReport) -> list[str]:
|
|
296
|
+
if not report.rows:
|
|
297
|
+
return ["No saved picks were found for this draft."]
|
|
298
|
+
|
|
299
|
+
recommended_values = tuple(_format_recommended(row=row) for row in report.rows)
|
|
300
|
+
actual_values = tuple(_format_actual(row=row) for row in report.rows)
|
|
301
|
+
recommended_width = max(len("Recommended"), *(len(value) for value in recommended_values))
|
|
302
|
+
actual_width = max(len("Actual"), *(len(value) for value in actual_values))
|
|
303
|
+
lines = [
|
|
304
|
+
"Pack Pick Pool 17L WR DO "
|
|
305
|
+
f"{'Recommended':<{recommended_width}} "
|
|
306
|
+
f"{'Actual':<{actual_width}} "
|
|
307
|
+
"Match"
|
|
308
|
+
]
|
|
309
|
+
for row, recommended, actual in zip(
|
|
310
|
+
report.rows,
|
|
311
|
+
recommended_values,
|
|
312
|
+
actual_values,
|
|
313
|
+
strict=True,
|
|
314
|
+
):
|
|
315
|
+
lines.append(
|
|
316
|
+
f"{row.pack_number + 1:>4} "
|
|
317
|
+
f"{row.pick_number + 1:>4} "
|
|
318
|
+
f"{_format_optional_int(row.pool_size):>4} "
|
|
319
|
+
f"{_format_win_rate(row=row):>6} "
|
|
320
|
+
f"{_format_score(row=row):>3} "
|
|
321
|
+
f"{recommended:<{recommended_width}} "
|
|
322
|
+
f"{actual:<{actual_width}} "
|
|
323
|
+
f"{_format_match(row=row)}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return lines
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _format_summary(*, report: BacktestReport) -> list[str]:
|
|
330
|
+
compared_count = len(report.compared_rows)
|
|
331
|
+
if compared_count == 0:
|
|
332
|
+
lines = [
|
|
333
|
+
"Summary: no comparable picks; "
|
|
334
|
+
f"{len(report.skipped_rows)} skipped."
|
|
335
|
+
]
|
|
336
|
+
else:
|
|
337
|
+
match_rate = report.match_count / compared_count
|
|
338
|
+
lines = [
|
|
339
|
+
"Summary: "
|
|
340
|
+
f"{report.match_count}/{compared_count} recommendations matched "
|
|
341
|
+
f"actual picks ({match_rate:.1%})."
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
if report.skipped_rows:
|
|
345
|
+
lines.append(
|
|
346
|
+
"Skipped picks were not scored when saved offered-card or "
|
|
347
|
+
"pool-before-pick history was missing."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return lines
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _format_recommended(*, row: BacktestPickResult) -> str:
|
|
354
|
+
if row.recommended is not None:
|
|
355
|
+
return format_card_info(row.recommended.card)
|
|
356
|
+
|
|
357
|
+
reason = row.skipped_reason or "not scored"
|
|
358
|
+
return f"skipped: {reason}"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _format_actual(*, row: BacktestPickResult) -> str:
|
|
362
|
+
if row.actual is not None:
|
|
363
|
+
return format_card_info(row.actual)
|
|
364
|
+
|
|
365
|
+
return row.skipped_reason or "missing actual selected card"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _format_win_rate(*, row: BacktestPickResult) -> str:
|
|
369
|
+
if row.recommended is None or row.recommended.rating.gih_win_rate is None:
|
|
370
|
+
return "—"
|
|
371
|
+
|
|
372
|
+
return f"{row.recommended.rating.gih_win_rate:.1%}"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _format_score(*, row: BacktestPickResult) -> str:
|
|
376
|
+
if row.recommended is None:
|
|
377
|
+
return "—"
|
|
378
|
+
|
|
379
|
+
return str(row.recommended.score)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _format_match(*, row: BacktestPickResult) -> str:
|
|
383
|
+
if row.match is None:
|
|
384
|
+
return "skipped"
|
|
385
|
+
|
|
386
|
+
return _yes_no(row.match)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _format_optional_int(value: int | None) -> str:
|
|
390
|
+
if value is None:
|
|
391
|
+
return "—"
|
|
392
|
+
|
|
393
|
+
return str(value)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _format_account(*, state: DraftState) -> str:
|
|
397
|
+
if state.account_screen_name is None:
|
|
398
|
+
return state.account_id
|
|
399
|
+
|
|
400
|
+
return f"{state.account_screen_name} ({state.account_id})"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _format_data_sources(*, report: BacktestReport) -> str:
|
|
404
|
+
if not report.data_sources:
|
|
405
|
+
return "none"
|
|
406
|
+
|
|
407
|
+
return "; ".join(report.data_sources)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _yes_no(value: bool) -> str:
|
|
411
|
+
return "yes" if value else "no"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _matching_states(
|
|
415
|
+
*,
|
|
416
|
+
states: tuple[DraftState, ...],
|
|
417
|
+
account_id: str | None,
|
|
418
|
+
draft_id: str | None,
|
|
419
|
+
) -> tuple[DraftState, ...]:
|
|
420
|
+
return tuple(
|
|
421
|
+
state
|
|
422
|
+
for state in states
|
|
423
|
+
if (account_id is None or state.account_id == account_id)
|
|
424
|
+
and (draft_id is None or state.draft_id == draft_id)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _latest_state(*, states: tuple[DraftState, ...]) -> DraftState:
|
|
429
|
+
return max(
|
|
430
|
+
states,
|
|
431
|
+
key=lambda state: (state.updated_at, state.account_id, state.draft_id),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _missing_persisted_draft_message(
|
|
436
|
+
*,
|
|
437
|
+
account_id: str | None,
|
|
438
|
+
draft_id: str | None,
|
|
439
|
+
) -> str:
|
|
440
|
+
if account_id is not None and draft_id is not None:
|
|
441
|
+
return f"No persisted draft found for account {account_id!r} and draft {draft_id!r}."
|
|
442
|
+
|
|
443
|
+
if account_id is not None:
|
|
444
|
+
return f"No persisted draft found for account {account_id!r}."
|
|
445
|
+
|
|
446
|
+
if draft_id is not None:
|
|
447
|
+
return f"No persisted draft found for draft {draft_id!r}."
|
|
448
|
+
|
|
449
|
+
return "No persisted drafts found. Replay/watch a draft first."
|