okstra 0.191.2 → 0.192.0
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.
- package/docs/architecture.md +2 -2
- package/docs/cli.md +2 -1
- package/docs/project-structure-overview.md +3 -1
- package/docs/task-process/README.md +2 -2
- package/docs/task-process/common-flow.md +4 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/convergence.md +7 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -8
- package/runtime/prompts/wizard/prompts.ko.json +52 -23
- package/runtime/python/okstra_ctl/convergence.py +63 -1
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +231 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
- package/runtime/python/okstra_ctl/next_phase.py +18 -8
- package/runtime/python/okstra_ctl/plan_items.py +6 -4
- package/runtime/python/okstra_ctl/report_finalize.py +57 -10
- package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
- package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
- package/runtime/python/okstra_ctl/wizard/state.py +39 -27
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +2 -2
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
"""역할 인스턴스 선택 루프 — `next_role_prompt` 와 역할
|
|
1
|
+
"""역할 인스턴스 선택 루프 — `next_role_prompt` 와 역할 모델 픽의 검증·되감기.
|
|
2
|
+
|
|
3
|
+
역할 하나에 화면 하나다. 여러 인스턴스를 띄우는 역할(analyser·designer·planner·
|
|
4
|
+
verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인스턴스 수다 —
|
|
5
|
+
"몇 개를 띄울까" 를 따로 묻지 않는다(종전 `role-count:` 화면, 실측 2026-09-09
|
|
6
|
+
사용자 요청으로 제거). 허용 범위와 권장 수는 화면 문구가 말한다. 선택 역할
|
|
7
|
+
(`min = 0`, 예: critic)은 같은 화면에 "추가 안 함" 줄이 있다(종전 `role-add:`).
|
|
8
|
+
고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.
|
|
9
|
+
|
|
10
|
+
체크박스 화면은 두 장이다. `role-models:<role>` 은 기본 후보(프로젝트
|
|
11
|
+
`modelDefaults` 또는 카탈로그 기본값)만 싣고 권장 수만큼 앞줄을 추천으로 표시한다
|
|
12
|
+
— 호스트 네이티브 선택기의 옵션 한도(claude-code 4, codex 3, grok 15) 안에
|
|
13
|
+
들어가야 체크박스로 렌더되기 때문이다. 마지막 줄 "직접 선택" 이
|
|
14
|
+
`role-models-custom:<role>` 을 열고, 거기에 실행 가능한 전체 후보가 실린다.
|
|
15
|
+
"""
|
|
2
16
|
from __future__ import annotations
|
|
3
17
|
|
|
4
18
|
from pathlib import Path
|
|
@@ -23,6 +37,7 @@ from okstra_ctl.run import (
|
|
|
23
37
|
_model_default_scopes,
|
|
24
38
|
)
|
|
25
39
|
|
|
40
|
+
from .ids import PICK_TYPE_CUSTOM
|
|
26
41
|
from .state import (
|
|
27
42
|
Option,
|
|
28
43
|
Prompt,
|
|
@@ -35,17 +50,37 @@ from .state import (
|
|
|
35
50
|
)
|
|
36
51
|
from .prompts import _opt, _p
|
|
37
52
|
|
|
53
|
+
# 선택 역할의 "추가 안 함" 값. 모델 참조는 `<provider>/<model>` 꼴이라 겹치지 않는다.
|
|
54
|
+
ROLE_SKIP_TOKEN = "0"
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
_MODELS_PREFIX = "role-models:"
|
|
57
|
+
_CUSTOM_PREFIX = "role-models-custom:"
|
|
58
|
+
_SINGLE_PREFIX = "role-model:"
|
|
41
59
|
|
|
42
60
|
|
|
43
|
-
def
|
|
44
|
-
return f"
|
|
61
|
+
def _role_models_prompt_id(role: str) -> str:
|
|
62
|
+
return f"{_MODELS_PREFIX}{role}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _role_models_custom_prompt_id(role: str) -> str:
|
|
66
|
+
return f"{_CUSTOM_PREFIX}{role}"
|
|
45
67
|
|
|
46
68
|
|
|
47
69
|
def _role_model_prompt_id(role: str, ordinal: int) -> str:
|
|
48
|
-
return f"
|
|
70
|
+
return f"{_SINGLE_PREFIX}{role}:{ordinal}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _step_role(step_id: str) -> str:
|
|
74
|
+
"""역할 선택 step id 가 가리키는 역할. 역할 선택 step 이 아니면 빈 문자열."""
|
|
75
|
+
for prefix in (_CUSTOM_PREFIX, _MODELS_PREFIX, _SINGLE_PREFIX):
|
|
76
|
+
if step_id.startswith(prefix):
|
|
77
|
+
return step_id[len(prefix):].split(":", 1)[0]
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fixed_single(requirement: RoleRequirement) -> bool:
|
|
82
|
+
"""정확히 한 인스턴스만 두는 역할 — 단일 선택 화면이다."""
|
|
83
|
+
return requirement.min_count == requirement.max_count == 1
|
|
49
84
|
|
|
50
85
|
|
|
51
86
|
def _selected_role_count(
|
|
@@ -74,93 +109,34 @@ def _selectable_static_requirements(
|
|
|
74
109
|
)
|
|
75
110
|
|
|
76
111
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
prompt = _p(
|
|
82
|
-
state.workspace_root,
|
|
83
|
-
"role_count",
|
|
84
|
-
role=requirement.role,
|
|
85
|
-
minimum=str(requirement.min_count),
|
|
86
|
-
maximum=str(requirement.max_count),
|
|
87
|
-
default=str(requirement.recommended_count),
|
|
88
|
-
)
|
|
89
|
-
return Prompt(
|
|
90
|
-
step=_role_count_prompt_id(requirement.role),
|
|
91
|
-
kind="pick",
|
|
92
|
-
label=prompt["label"],
|
|
93
|
-
options=[
|
|
94
|
-
_opt(
|
|
95
|
-
str(count),
|
|
96
|
-
prompt["options"]["count"].format(
|
|
97
|
-
count=count,
|
|
98
|
-
default_suffix=(
|
|
99
|
-
prompt["options"].get("default_suffix", "")
|
|
100
|
-
if count == requirement.recommended_count
|
|
101
|
-
else ""
|
|
102
|
-
),
|
|
103
|
-
),
|
|
104
|
-
)
|
|
105
|
-
for count in range(requirement.min_count, requirement.max_count + 1)
|
|
106
|
-
],
|
|
107
|
-
echo_template=prompt["echo_template"],
|
|
112
|
+
def _static_requirements(profile: RoleProfile) -> tuple[RoleRequirement, ...]:
|
|
113
|
+
return tuple(
|
|
114
|
+
requirement for requirement in profile.roles
|
|
115
|
+
if not requirement.dynamic and requirement.max_count > 0
|
|
108
116
|
)
|
|
109
117
|
|
|
110
118
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
prompt = _p(
|
|
121
|
-
state.workspace_root,
|
|
122
|
-
"role_add",
|
|
123
|
-
role=requirement.role,
|
|
124
|
-
maximum=str(requirement.max_count),
|
|
125
|
-
)
|
|
126
|
-
suffix = prompt["options"].get("default_suffix", "")
|
|
127
|
-
# 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 0 옵션에
|
|
128
|
-
# 경고를 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다.
|
|
129
|
-
skip_warnings = prompt["options"].get("skip_warnings", {})
|
|
130
|
-
skip_warning = (
|
|
131
|
-
skip_warnings.get(requirement.role, "")
|
|
132
|
-
if isinstance(skip_warnings, dict)
|
|
133
|
-
else ""
|
|
119
|
+
def _count_range_text(requirement: RoleRequirement, t: dict) -> str:
|
|
120
|
+
"""화면 문구의 허용 범위: "허용 2..5개, 권장 3개" 또는 "정확히 2개"."""
|
|
121
|
+
ranges = t["labels"]
|
|
122
|
+
if requirement.min_count == requirement.max_count:
|
|
123
|
+
return ranges["exact"].format(count=requirement.max_count)
|
|
124
|
+
return ranges["range"].format(
|
|
125
|
+
minimum=requirement.min_count,
|
|
126
|
+
maximum=requirement.max_count,
|
|
127
|
+
recommended=requirement.recommended_count,
|
|
134
128
|
)
|
|
135
129
|
|
|
136
|
-
def _default_suffix(count: int) -> str:
|
|
137
|
-
return suffix if count == requirement.recommended_count else ""
|
|
138
130
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
for count in range(1, requirement.max_count + 1):
|
|
149
|
-
options.append(
|
|
150
|
-
_opt(
|
|
151
|
-
str(count),
|
|
152
|
-
prompt["options"]["add"].format(
|
|
153
|
-
count=count, default_suffix=_default_suffix(count),
|
|
154
|
-
),
|
|
155
|
-
)
|
|
156
|
-
)
|
|
157
|
-
return Prompt(
|
|
158
|
-
step=_role_add_prompt_id(requirement.role),
|
|
159
|
-
kind="pick",
|
|
160
|
-
label=prompt["label"],
|
|
161
|
-
options=options,
|
|
162
|
-
echo_template=prompt["echo_template"],
|
|
163
|
-
)
|
|
131
|
+
def _role_models_texts(
|
|
132
|
+
state: WizardState, prompt_key: str, requirement: RoleRequirement,
|
|
133
|
+
) -> dict:
|
|
134
|
+
"""`role_models` / `role_models_custom` 문구. 허용 범위 문장은 `range` 키다."""
|
|
135
|
+
probe = _p(state.workspace_root, prompt_key, role=requirement.role, range="")
|
|
136
|
+
range_text = _count_range_text(requirement, probe)
|
|
137
|
+
texts = _p(state.workspace_root, prompt_key, role=requirement.role, range=range_text)
|
|
138
|
+
texts["range"] = range_text
|
|
139
|
+
return texts
|
|
164
140
|
|
|
165
141
|
|
|
166
142
|
def _role_default_candidates(
|
|
@@ -193,55 +169,153 @@ def _role_default_candidates(
|
|
|
193
169
|
)
|
|
194
170
|
|
|
195
171
|
|
|
196
|
-
def
|
|
172
|
+
def _available_role_models(
|
|
173
|
+
state: WizardState,
|
|
174
|
+
profile: RoleProfile,
|
|
175
|
+
requirement: RoleRequirement,
|
|
176
|
+
context: AssignmentContext,
|
|
177
|
+
scopes: ModelDefaultScopes,
|
|
178
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
179
|
+
"""(기본 후보, 실행 가능한 전체 후보). 전체 후보는 기본 후보를 앞에 둔다.
|
|
180
|
+
|
|
181
|
+
실행 가능 여부는 prepare 와 같은 배정 스냅샷(`resolve_model_assignment`)으로
|
|
182
|
+
판정한다 — 카탈로그에는 있지만 이 호스트·환경에서 못 띄우는 모델은 싣지
|
|
183
|
+
않는다.
|
|
184
|
+
"""
|
|
185
|
+
role = requirement.role
|
|
186
|
+
executable = set(_executable_role_models(state, profile, requirement, context))
|
|
187
|
+
defaults = tuple(
|
|
188
|
+
model_ref
|
|
189
|
+
for model_ref in _role_default_candidates(state, role, context.pool, scopes)
|
|
190
|
+
if model_ref in executable
|
|
191
|
+
)
|
|
192
|
+
everything = tuple(dict.fromkeys([
|
|
193
|
+
*defaults,
|
|
194
|
+
*(
|
|
195
|
+
str(model.model_ref) for model in context.pool.list(role=role)
|
|
196
|
+
if str(model.model_ref) in executable
|
|
197
|
+
),
|
|
198
|
+
]))
|
|
199
|
+
return defaults, everything
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _shortlist_budget(state: WizardState, requirement: RoleRequirement) -> int:
|
|
203
|
+
"""`role-models:` 화면에 실을 모델 줄 수.
|
|
204
|
+
|
|
205
|
+
호스트에 네이티브 선택기가 있으면 그 한도에서 "직접 선택" 한 줄과, 선택
|
|
206
|
+
역할이면 "추가 안 함" 한 줄을 뺀 수다. 한도를 넘기는 순간
|
|
207
|
+
`CapabilityInteractionPort.plan` 이 번호 목록으로 내려 체크박스가 아니게
|
|
208
|
+
된다. 선택기 자체가 없는 호스트는 어차피 번호 목록이므로 자르지 않는다.
|
|
209
|
+
"""
|
|
210
|
+
functions = set(state.available_functions)
|
|
211
|
+
if "native_single_select" not in functions and "native_multi_select" not in functions:
|
|
212
|
+
return 10**6
|
|
213
|
+
port = default_host_registry().resolve(state.host_runtime).interaction()
|
|
214
|
+
reserved = 1 + (1 if requirement.min_count == 0 else 0)
|
|
215
|
+
return max(1, port.native_option_limit - reserved)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _skip_option(requirement: RoleRequirement, t: dict, *, recommended: bool) -> Option:
|
|
219
|
+
# 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 경고를
|
|
220
|
+
# 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다.
|
|
221
|
+
warnings = t["options"].get("skip_warnings", {})
|
|
222
|
+
warning = warnings.get(requirement.role, "") if isinstance(warnings, dict) else ""
|
|
223
|
+
return _opt(
|
|
224
|
+
ROLE_SKIP_TOKEN,
|
|
225
|
+
t["options"]["skip"].format(skip_warning=warning),
|
|
226
|
+
recommended=recommended,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _role_models_prompt(
|
|
231
|
+
state: WizardState,
|
|
232
|
+
profile: RoleProfile,
|
|
233
|
+
requirement: RoleRequirement,
|
|
234
|
+
context: AssignmentContext,
|
|
235
|
+
scopes: ModelDefaultScopes,
|
|
236
|
+
*,
|
|
237
|
+
full: bool,
|
|
238
|
+
) -> Prompt:
|
|
239
|
+
"""역할 하나의 모델 화면. `full` 이면 전체 후보, 아니면 기본 후보 + 직접 선택.
|
|
240
|
+
|
|
241
|
+
추천은 권장 수만큼의 앞줄이다 — 프로젝트 `modelDefaults`(없으면 카탈로그
|
|
242
|
+
기본값) 순서가 그 근거다. 권장이 0인 선택 역할은 "추가 안 함" 이 추천이다.
|
|
243
|
+
"""
|
|
244
|
+
role = requirement.role
|
|
245
|
+
pool = context.pool
|
|
246
|
+
defaults, everything = _available_role_models(
|
|
247
|
+
state, profile, requirement, context, scopes,
|
|
248
|
+
)
|
|
249
|
+
if not everything:
|
|
250
|
+
_validate_role_selection_feasibility(state, profile, context, scopes)
|
|
251
|
+
raise WizardError(f"role {role!r} has no executable model candidates")
|
|
252
|
+
custom = full
|
|
253
|
+
if not full:
|
|
254
|
+
shown = defaults[: _shortlist_budget(state, requirement)]
|
|
255
|
+
# 기본 후보만으로 최소 수를 못 채우면 줄인 목록은 답이 될 수 없다 —
|
|
256
|
+
# 같은 화면에 전체 후보를 싣는다.
|
|
257
|
+
if len(shown) < min(requirement.min_count, len(everything)) or not shown:
|
|
258
|
+
full = True
|
|
259
|
+
if full:
|
|
260
|
+
shown = everything
|
|
261
|
+
prompt_key = "role_models_custom" if custom else "role_models"
|
|
262
|
+
t = _role_models_texts(state, prompt_key, requirement)
|
|
263
|
+
label = t["label"]
|
|
264
|
+
optional = requirement.min_count == 0
|
|
265
|
+
skip_first = optional and requirement.recommended_count == 0
|
|
266
|
+
options: list[Option] = []
|
|
267
|
+
if skip_first:
|
|
268
|
+
options.append(_skip_option(requirement, t, recommended=True))
|
|
269
|
+
for index, model_ref in enumerate(shown):
|
|
270
|
+
model = pool.resolve(model_ref)
|
|
271
|
+
options.append(_opt(
|
|
272
|
+
model_ref,
|
|
273
|
+
t["options"]["model"].format(model_ref=model_ref, display=model.display_name),
|
|
274
|
+
recommended=index < requirement.recommended_count,
|
|
275
|
+
))
|
|
276
|
+
if optional and not skip_first:
|
|
277
|
+
options.append(_skip_option(requirement, t, recommended=False))
|
|
278
|
+
if not full:
|
|
279
|
+
options.append(_opt(
|
|
280
|
+
PICK_TYPE_CUSTOM,
|
|
281
|
+
t["options"][PICK_TYPE_CUSTOM].format(total=len(everything)),
|
|
282
|
+
))
|
|
283
|
+
return Prompt(
|
|
284
|
+
step=(
|
|
285
|
+
_role_models_custom_prompt_id(role) if custom
|
|
286
|
+
else _role_models_prompt_id(role)
|
|
287
|
+
),
|
|
288
|
+
kind="pick",
|
|
289
|
+
multi=requirement.max_count > 1,
|
|
290
|
+
label=label,
|
|
291
|
+
options=options,
|
|
292
|
+
echo_template=t["echo_template"],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _single_model_options(
|
|
197
297
|
state: WizardState,
|
|
198
298
|
profile: RoleProfile,
|
|
199
299
|
role: str,
|
|
200
|
-
ordinal: int,
|
|
201
300
|
context: AssignmentContext,
|
|
202
301
|
scopes: ModelDefaultScopes,
|
|
203
302
|
) -> list[Option]:
|
|
303
|
+
"""고정 단일 역할의 후보 전체. 기본 후보가 앞이고 첫 줄이 추천이다."""
|
|
204
304
|
pool = context.pool
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
defaults = (*defaults[offset:], *defaults[:offset])
|
|
209
|
-
candidate_refs = [
|
|
210
|
-
*defaults,
|
|
211
|
-
*(str(model.model_ref) for model in pool.list(role=role)),
|
|
212
|
-
]
|
|
213
|
-
ordered_refs = tuple(dict.fromkeys(candidate_refs))
|
|
214
|
-
default_refs = frozenset(defaults)
|
|
215
|
-
prompt = _p(
|
|
216
|
-
state.workspace_root,
|
|
217
|
-
"role_model",
|
|
218
|
-
role=role,
|
|
219
|
-
ordinal="1",
|
|
220
|
-
count="1",
|
|
305
|
+
requirement = next(row for row in profile.roles if row.role == role)
|
|
306
|
+
defaults, everything = _available_role_models(
|
|
307
|
+
state, profile, requirement, context, scopes,
|
|
221
308
|
)
|
|
309
|
+
prompt = _p(state.workspace_root, "role_model", role=role)
|
|
222
310
|
options: list[Option] = []
|
|
223
|
-
for model_ref in
|
|
224
|
-
availability = pool.availability(
|
|
225
|
-
model_ref,
|
|
226
|
-
role,
|
|
227
|
-
state.host_runtime,
|
|
228
|
-
"new-session",
|
|
229
|
-
)
|
|
230
|
-
if not availability.available:
|
|
231
|
-
continue
|
|
311
|
+
for model_ref in everything:
|
|
232
312
|
candidate_models = {
|
|
233
313
|
selected_role: tuple(models)
|
|
234
314
|
for selected_role, models in state.role_models.items()
|
|
235
315
|
}
|
|
236
|
-
|
|
237
|
-
selected.append(model_ref)
|
|
238
|
-
candidate_models[role] = tuple(selected)
|
|
316
|
+
candidate_models[role] = (model_ref,)
|
|
239
317
|
if not _role_selection_can_complete(
|
|
240
|
-
state,
|
|
241
|
-
profile,
|
|
242
|
-
context,
|
|
243
|
-
candidate_models,
|
|
244
|
-
scopes,
|
|
318
|
+
state, profile, context, candidate_models, scopes,
|
|
245
319
|
):
|
|
246
320
|
continue
|
|
247
321
|
model = pool.resolve(model_ref)
|
|
@@ -252,10 +326,11 @@ def _role_model_options(
|
|
|
252
326
|
display=model.display_name,
|
|
253
327
|
default_suffix=(
|
|
254
328
|
prompt["options"].get("default_suffix", "")
|
|
255
|
-
if model_ref in
|
|
329
|
+
if model_ref in defaults
|
|
256
330
|
else ""
|
|
257
331
|
),
|
|
258
332
|
),
|
|
333
|
+
recommended=not options,
|
|
259
334
|
))
|
|
260
335
|
if not options:
|
|
261
336
|
_validate_role_selection_feasibility(state, profile, context, scopes)
|
|
@@ -267,60 +342,36 @@ def _model_prompt(
|
|
|
267
342
|
state: WizardState,
|
|
268
343
|
profile: RoleProfile,
|
|
269
344
|
requirement: RoleRequirement,
|
|
270
|
-
ordinal: int,
|
|
271
345
|
context: AssignmentContext,
|
|
272
346
|
scopes: ModelDefaultScopes,
|
|
273
347
|
) -> Prompt:
|
|
274
|
-
prompt = _p(
|
|
275
|
-
state.workspace_root,
|
|
276
|
-
"role_model",
|
|
277
|
-
role=requirement.role,
|
|
278
|
-
ordinal=str(ordinal),
|
|
279
|
-
count=str(_selected_role_count(state, requirement)),
|
|
280
|
-
)
|
|
348
|
+
prompt = _p(state.workspace_root, "role_model", role=requirement.role)
|
|
281
349
|
return Prompt(
|
|
282
|
-
step=_role_model_prompt_id(requirement.role,
|
|
350
|
+
step=_role_model_prompt_id(requirement.role, 1),
|
|
283
351
|
kind="pick",
|
|
284
352
|
label=prompt["label"],
|
|
285
|
-
options=
|
|
286
|
-
state,
|
|
287
|
-
profile,
|
|
288
|
-
requirement.role,
|
|
289
|
-
ordinal,
|
|
290
|
-
context,
|
|
291
|
-
scopes,
|
|
353
|
+
options=_single_model_options(
|
|
354
|
+
state, profile, requirement.role, context, scopes,
|
|
292
355
|
),
|
|
293
356
|
echo_template=prompt["echo_template"],
|
|
294
357
|
)
|
|
295
358
|
|
|
296
359
|
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
invalid_ids: set[str] = set()
|
|
306
|
-
for later_index, later_requirement in enumerate(
|
|
307
|
-
requirements[requirement_index:],
|
|
308
|
-
start=requirement_index,
|
|
309
|
-
):
|
|
310
|
-
first_ordinal = model_index + 1 if later_index == requirement_index else 1
|
|
311
|
-
later_count = _selected_role_count(state, later_requirement)
|
|
312
|
-
invalid_ids.update(
|
|
313
|
-
_role_model_prompt_id(later_requirement.role, ordinal)
|
|
314
|
-
for ordinal in range(first_ordinal, later_count + 1)
|
|
315
|
-
)
|
|
316
|
-
if later_index != requirement_index:
|
|
317
|
-
state.role_models.pop(later_requirement.role, None)
|
|
360
|
+
def _drop_role_selection(state: WizardState, roles: set[str]) -> None:
|
|
361
|
+
"""이 역할들의 선택값과 step 기록을 지운다."""
|
|
362
|
+
for role in roles:
|
|
363
|
+
state.role_models.pop(role, None)
|
|
364
|
+
state.role_counts.pop(role, None)
|
|
365
|
+
state.role_models_custom = [
|
|
366
|
+
role for role in state.role_models_custom if role not in roles
|
|
367
|
+
]
|
|
318
368
|
state.role_selection_order = [
|
|
319
369
|
step_id for step_id in state.role_selection_order
|
|
320
|
-
if step_id not in
|
|
370
|
+
if _step_role(step_id) not in roles
|
|
321
371
|
]
|
|
322
372
|
state.answered = [
|
|
323
|
-
step_id for step_id in state.answered
|
|
373
|
+
step_id for step_id in state.answered
|
|
374
|
+
if not (_is_role_selection_step(step_id) and _step_role(step_id) in roles)
|
|
324
375
|
]
|
|
325
376
|
|
|
326
377
|
|
|
@@ -331,46 +382,35 @@ def _discard_invalid_previous_models(
|
|
|
331
382
|
context: AssignmentContext,
|
|
332
383
|
scopes: ModelDefaultScopes,
|
|
333
384
|
) -> None:
|
|
385
|
+
"""재개한 상태의 선택값이 지금의 배정 스냅샷에서 무효면 그 역할부터 되감는다.
|
|
386
|
+
|
|
387
|
+
무효 판정은 역할 단위다 — 체크박스 한 장이 그 역할의 답이므로, 그 안의 모델
|
|
388
|
+
하나가 빠졌어도 같은 화면을 다시 묻는다. 그 뒤 역할들은 앞 역할의 답에
|
|
389
|
+
따라 후보가 달라지므로 함께 비운다.
|
|
390
|
+
"""
|
|
334
391
|
selected_models = {
|
|
335
392
|
role: tuple(models) for role, models in state.role_models.items()
|
|
336
393
|
}
|
|
337
394
|
if _role_selection_error(
|
|
338
|
-
state,
|
|
339
|
-
profile,
|
|
340
|
-
context,
|
|
341
|
-
selected_models,
|
|
342
|
-
scopes,
|
|
395
|
+
state, profile, context, selected_models, scopes,
|
|
343
396
|
) is None:
|
|
344
397
|
return
|
|
345
|
-
|
|
346
|
-
for
|
|
347
|
-
count = _selected_role_count(state, requirement)
|
|
398
|
+
prefix: dict[str, tuple[str, ...]] = {}
|
|
399
|
+
for index, requirement in enumerate(requirements):
|
|
348
400
|
selected = state.role_models.get(requirement.role, [])
|
|
349
|
-
if
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
state,
|
|
359
|
-
profile,
|
|
360
|
-
context,
|
|
361
|
-
candidate_prefix,
|
|
362
|
-
scopes,
|
|
363
|
-
):
|
|
364
|
-
continue
|
|
365
|
-
_rewind_invalid_role_models(
|
|
366
|
-
state,
|
|
367
|
-
requirements,
|
|
368
|
-
requirement_index,
|
|
369
|
-
index,
|
|
401
|
+
if not selected:
|
|
402
|
+
continue
|
|
403
|
+
count = _selected_role_count(state, requirement)
|
|
404
|
+
candidate = dict(prefix)
|
|
405
|
+
candidate[requirement.role] = tuple(selected)
|
|
406
|
+
if len(selected) > count or not _role_selection_can_complete(
|
|
407
|
+
state, profile, context, candidate, scopes,
|
|
408
|
+
):
|
|
409
|
+
_drop_role_selection(
|
|
410
|
+
state, {later.role for later in requirements[index:]},
|
|
370
411
|
)
|
|
371
412
|
return
|
|
372
|
-
|
|
373
|
-
selected_prefix[requirement.role] = tuple(selected)
|
|
413
|
+
prefix[requirement.role] = tuple(selected)
|
|
374
414
|
|
|
375
415
|
|
|
376
416
|
def _host_session_context(state: WizardState) -> HostSessionContext:
|
|
@@ -475,11 +515,7 @@ def _role_selection_can_complete(
|
|
|
475
515
|
) -> bool:
|
|
476
516
|
completed = _completed_role_models(state, profile, context, role_models)
|
|
477
517
|
return completed is not None and _role_selection_error(
|
|
478
|
-
state,
|
|
479
|
-
profile,
|
|
480
|
-
context,
|
|
481
|
-
completed,
|
|
482
|
-
scopes,
|
|
518
|
+
state, profile, context, completed, scopes,
|
|
483
519
|
) is None
|
|
484
520
|
|
|
485
521
|
|
|
@@ -507,45 +543,7 @@ def _validate_role_selection_feasibility(
|
|
|
507
543
|
raise WizardError(error)
|
|
508
544
|
|
|
509
545
|
|
|
510
|
-
def
|
|
511
|
-
"""Ask every adjustable count before one model question per role instance."""
|
|
512
|
-
if not _role_selection_enabled(state) or not _identity_ready(state):
|
|
513
|
-
return None
|
|
514
|
-
state.use_defaults = False
|
|
515
|
-
profile = _load_role_profile_for_state(state)
|
|
516
|
-
for requirement in profile.roles:
|
|
517
|
-
# 필수 수량: min < max 이고 min > 0 일 때만.
|
|
518
|
-
if (
|
|
519
|
-
requirement.dynamic
|
|
520
|
-
or requirement.min_count == requirement.max_count
|
|
521
|
-
or requirement.min_count == 0
|
|
522
|
-
):
|
|
523
|
-
continue
|
|
524
|
-
if requirement.role not in state.role_counts:
|
|
525
|
-
return _count_prompt(state, requirement)
|
|
526
|
-
count = state.role_counts[requirement.role]
|
|
527
|
-
if count < requirement.min_count or count > requirement.max_count:
|
|
528
|
-
raise WizardError(
|
|
529
|
-
f"role {requirement.role!r} count must be in "
|
|
530
|
-
f"{requirement.min_count}..{requirement.max_count}: {count}"
|
|
531
|
-
)
|
|
532
|
-
for requirement in profile.roles:
|
|
533
|
-
# 선택 역할(min=0, max>0): 기본은 추가 안 함. role-add 로만 연다.
|
|
534
|
-
if (
|
|
535
|
-
requirement.dynamic
|
|
536
|
-
or requirement.min_count != 0
|
|
537
|
-
or requirement.max_count == 0
|
|
538
|
-
):
|
|
539
|
-
continue
|
|
540
|
-
if requirement.role not in state.role_counts:
|
|
541
|
-
return _role_add_prompt(state, requirement)
|
|
542
|
-
count = state.role_counts[requirement.role]
|
|
543
|
-
if count < 0 or count > requirement.max_count:
|
|
544
|
-
raise WizardError(
|
|
545
|
-
f"role {requirement.role!r} count must be in "
|
|
546
|
-
f"0..{requirement.max_count}: {count}"
|
|
547
|
-
)
|
|
548
|
-
requirements = _selectable_static_requirements(state, profile)
|
|
546
|
+
def _load_context(state: WizardState) -> tuple[AssignmentContext, ModelDefaultScopes]:
|
|
549
547
|
try:
|
|
550
548
|
scopes = _model_default_scopes(Path(state.project_root))
|
|
551
549
|
except (PrepareError, ValueError) as exc:
|
|
@@ -554,41 +552,123 @@ def next_role_prompt(state: WizardState) -> Prompt | None:
|
|
|
554
552
|
host_runtime=state.host_runtime,
|
|
555
553
|
terminal_backend=detect_terminal_backend(),
|
|
556
554
|
)
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
555
|
+
return context, scopes
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def next_role_prompt(state: WizardState) -> Prompt | None:
|
|
559
|
+
"""프로필 순서대로 역할마다 화면 하나 — 답이 없는 첫 역할의 화면을 낸다."""
|
|
560
|
+
if not _role_selection_enabled(state) or not _identity_ready(state):
|
|
561
|
+
return None
|
|
562
|
+
state.use_defaults = False
|
|
563
|
+
profile = _load_role_profile_for_state(state)
|
|
564
|
+
requirements = _static_requirements(profile)
|
|
565
|
+
context, scopes = _load_context(state)
|
|
566
|
+
_discard_invalid_previous_models(state, profile, requirements, context, scopes)
|
|
564
567
|
for requirement in requirements:
|
|
565
|
-
|
|
566
|
-
selected = state.role_models.get(
|
|
567
|
-
if
|
|
568
|
-
|
|
568
|
+
role = requirement.role
|
|
569
|
+
selected = state.role_models.get(role, [])
|
|
570
|
+
if _fixed_single(requirement):
|
|
571
|
+
if not selected:
|
|
572
|
+
return _model_prompt(state, profile, requirement, context, scopes)
|
|
573
|
+
continue
|
|
574
|
+
if role in state.role_models_custom:
|
|
575
|
+
return _role_models_prompt(
|
|
576
|
+
state, profile, requirement, context, scopes, full=True,
|
|
577
|
+
)
|
|
578
|
+
if role not in state.role_counts:
|
|
579
|
+
return _role_models_prompt(
|
|
580
|
+
state, profile, requirement, context, scopes, full=False,
|
|
581
|
+
)
|
|
582
|
+
count = state.role_counts[role]
|
|
583
|
+
if count != len(selected) or not (
|
|
584
|
+
(0 if requirement.min_count == 0 else requirement.min_count)
|
|
585
|
+
<= count <= requirement.max_count
|
|
586
|
+
):
|
|
587
|
+
# 재개한 상태의 수와 모델이 어긋난다 — 그 역할부터 다시 묻는다.
|
|
588
|
+
_drop_role_selection(
|
|
569
589
|
state,
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
context,
|
|
574
|
-
scopes,
|
|
590
|
+
{later.role for later in requirements[requirements.index(requirement):]},
|
|
591
|
+
)
|
|
592
|
+
return _role_models_prompt(
|
|
593
|
+
state, profile, requirement, context, scopes, full=False,
|
|
575
594
|
)
|
|
576
595
|
_validate_role_selection_feasibility(state, profile, context, scopes)
|
|
577
596
|
return None
|
|
578
597
|
|
|
579
598
|
|
|
599
|
+
def _requirement_for(profile: RoleProfile, role: str) -> RoleRequirement:
|
|
600
|
+
requirement = next((row for row in profile.roles if row.role == role), None)
|
|
601
|
+
if requirement is None or requirement.dynamic or requirement.max_count == 0:
|
|
602
|
+
raise WizardError(f"role {role!r} does not accept a model selection")
|
|
603
|
+
return requirement
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _submit_role_models(
|
|
607
|
+
state: WizardState,
|
|
608
|
+
profile: RoleProfile,
|
|
609
|
+
requirement: RoleRequirement,
|
|
610
|
+
prompt: Prompt,
|
|
611
|
+
value: str,
|
|
612
|
+
) -> str:
|
|
613
|
+
"""체크박스(또는 선택 역할 단일 픽)의 답을 그 역할의 수와 모델로 확정한다."""
|
|
614
|
+
role = requirement.role
|
|
615
|
+
prompt_key = (
|
|
616
|
+
"role_models_custom" if prompt.step.startswith(_CUSTOM_PREFIX)
|
|
617
|
+
else "role_models"
|
|
618
|
+
)
|
|
619
|
+
t = _role_models_texts(state, prompt_key, requirement)
|
|
620
|
+
range_text = t["range"]
|
|
621
|
+
# 같은 값이 두 번 오면(번호 목록에서 `1,1`) 한 번으로 센다 — 거절할 일이 아니다.
|
|
622
|
+
picked = list(dict.fromkeys(
|
|
623
|
+
item.strip() for item in (value or "").split(",") if item.strip()
|
|
624
|
+
))
|
|
625
|
+
if PICK_TYPE_CUSTOM in picked:
|
|
626
|
+
if role not in state.role_models_custom:
|
|
627
|
+
state.role_models_custom.append(role)
|
|
628
|
+
return t["echo_variants"]["custom"]
|
|
629
|
+
allowed = {option.value for option in prompt.options}
|
|
630
|
+
unknown = [item for item in picked if item not in allowed]
|
|
631
|
+
if unknown:
|
|
632
|
+
raise WizardError(t["errors"]["unknown_option"].format(values=", ".join(unknown)))
|
|
633
|
+
models = [item for item in picked if item != ROLE_SKIP_TOKEN]
|
|
634
|
+
# "추가 안 함" 과 모델을 같이 고르면 모델을 고른 것이다.
|
|
635
|
+
if not models:
|
|
636
|
+
if ROLE_SKIP_TOKEN not in picked:
|
|
637
|
+
raise WizardError(t["errors"]["min_one_required"].format(range=range_text))
|
|
638
|
+
previous = (state.role_counts.get(role), state.role_models.get(role))
|
|
639
|
+
state.role_counts[role] = 0
|
|
640
|
+
state.role_models.pop(role, None)
|
|
641
|
+
state.role_models_custom = [r for r in state.role_models_custom if r != role]
|
|
642
|
+
return t["echo_variants"]["skipped"]
|
|
643
|
+
if not requirement.min_count <= len(models) <= requirement.max_count:
|
|
644
|
+
raise WizardError(t["errors"]["count_out_of_range"].format(
|
|
645
|
+
range=range_text, count=len(models),
|
|
646
|
+
))
|
|
647
|
+
previous_count = state.role_counts.get(role)
|
|
648
|
+
previous_models = state.role_models.get(role)
|
|
649
|
+
state.role_counts[role] = len(models)
|
|
650
|
+
state.role_models[role] = list(models)
|
|
651
|
+
state.role_models_custom = [r for r in state.role_models_custom if r != role]
|
|
652
|
+
try:
|
|
653
|
+
_validate_submitted_role_model(state, profile)
|
|
654
|
+
except WizardError:
|
|
655
|
+
if previous_count is None:
|
|
656
|
+
state.role_counts.pop(role, None)
|
|
657
|
+
else:
|
|
658
|
+
state.role_counts[role] = previous_count
|
|
659
|
+
if previous_models is None:
|
|
660
|
+
state.role_models.pop(role, None)
|
|
661
|
+
else:
|
|
662
|
+
state.role_models[role] = previous_models
|
|
663
|
+
raise
|
|
664
|
+
return t["echo_template"].format(value=f"{role}={','.join(models)}")
|
|
665
|
+
|
|
666
|
+
|
|
580
667
|
def _validate_submitted_role_model(
|
|
581
668
|
state: WizardState,
|
|
582
669
|
profile: RoleProfile,
|
|
583
670
|
) -> None:
|
|
584
|
-
|
|
585
|
-
scopes = _model_default_scopes(Path(state.project_root))
|
|
586
|
-
except (PrepareError, ValueError) as exc:
|
|
587
|
-
raise WizardError(str(exc)) from exc
|
|
588
|
-
context = load_assignment_context(
|
|
589
|
-
host_runtime=state.host_runtime,
|
|
590
|
-
terminal_backend=detect_terminal_backend(),
|
|
591
|
-
)
|
|
671
|
+
context, scopes = _load_context(state)
|
|
592
672
|
role_models = {
|
|
593
673
|
role: tuple(models) for role, models in state.role_models.items()
|
|
594
674
|
}
|
|
@@ -602,133 +682,56 @@ def _validate_submitted_role_model(
|
|
|
602
682
|
_validate_role_selection_feasibility(state, profile, context, scopes)
|
|
603
683
|
return
|
|
604
684
|
if not _role_selection_can_complete(
|
|
605
|
-
state,
|
|
606
|
-
profile,
|
|
607
|
-
context,
|
|
608
|
-
role_models,
|
|
609
|
-
scopes,
|
|
685
|
+
state, profile, context, role_models, scopes,
|
|
610
686
|
):
|
|
611
687
|
raise WizardError("selected role model leaves no complete role assignment")
|
|
612
688
|
|
|
613
689
|
|
|
614
690
|
def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
or requirement.min_count == requirement.max_count
|
|
626
|
-
or requirement.min_count == 0
|
|
627
|
-
):
|
|
628
|
-
raise WizardError(f"role {role!r} does not accept a count selection")
|
|
629
|
-
try:
|
|
630
|
-
count = int(value)
|
|
631
|
-
except ValueError as exc:
|
|
632
|
-
raise WizardError(f"role {role!r} count must be an integer") from exc
|
|
633
|
-
if count < requirement.min_count or count > requirement.max_count:
|
|
634
|
-
raise WizardError(
|
|
635
|
-
f"role {role!r} count must be in "
|
|
636
|
-
f"{requirement.min_count}..{requirement.max_count}: {count}"
|
|
637
|
-
)
|
|
638
|
-
state.role_counts[role] = count
|
|
639
|
-
state.role_models.pop(role, None)
|
|
640
|
-
return f"role-count: {role}={count}"
|
|
641
|
-
if prompt.step.startswith("role-add:"):
|
|
642
|
-
role = prompt.step.split(":", 1)[1]
|
|
643
|
-
profile = _load_role_profile_for_state(state)
|
|
644
|
-
requirement = next(
|
|
645
|
-
(row for row in profile.roles if row.role == role),
|
|
646
|
-
None,
|
|
647
|
-
)
|
|
648
|
-
if (
|
|
649
|
-
requirement is None
|
|
650
|
-
or requirement.dynamic
|
|
651
|
-
or requirement.min_count != 0
|
|
652
|
-
or requirement.max_count == 0
|
|
653
|
-
):
|
|
654
|
-
raise WizardError(f"role {role!r} does not accept an optional add")
|
|
655
|
-
allowed = {option.value for option in prompt.options}
|
|
656
|
-
if value not in allowed:
|
|
657
|
-
raise WizardError(
|
|
658
|
-
f"role {role!r} add selection must be one of "
|
|
659
|
-
f"{sorted(allowed, key=int)}: {value}"
|
|
660
|
-
)
|
|
661
|
-
try:
|
|
662
|
-
count = int(value)
|
|
663
|
-
except ValueError as exc:
|
|
664
|
-
raise WizardError(f"role {role!r} count must be an integer") from exc
|
|
665
|
-
if count < 0 or count > requirement.max_count:
|
|
666
|
-
raise WizardError(
|
|
667
|
-
f"role {role!r} count must be in "
|
|
668
|
-
f"0..{requirement.max_count}: {count}"
|
|
669
|
-
)
|
|
670
|
-
state.role_counts[role] = count
|
|
671
|
-
state.role_models.pop(role, None)
|
|
672
|
-
return f"role-add: {role}={count}"
|
|
673
|
-
_, role, ordinal_raw = prompt.step.split(":", 2)
|
|
674
|
-
ordinal = int(ordinal_raw)
|
|
691
|
+
profile = _load_role_profile_for_state(state)
|
|
692
|
+
role = _step_role(prompt.step)
|
|
693
|
+
requirement = _requirement_for(profile, role)
|
|
694
|
+
if prompt.step.startswith((_MODELS_PREFIX, _CUSTOM_PREFIX)):
|
|
695
|
+
if _fixed_single(requirement):
|
|
696
|
+
raise WizardError(f"role {role!r} takes exactly one model")
|
|
697
|
+
return _submit_role_models(state, profile, requirement, prompt, value)
|
|
698
|
+
# 고정 단일 역할의 `role-model:<role>:1`.
|
|
699
|
+
if not _fixed_single(requirement):
|
|
700
|
+
raise WizardError(f"role {role!r} is chosen on its checkbox step")
|
|
675
701
|
allowed = {option.value for option in prompt.options}
|
|
676
702
|
if value not in allowed:
|
|
677
703
|
raise WizardError(
|
|
678
704
|
f"model {value!r} is not a compatible candidate for role {role!r}"
|
|
679
705
|
)
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
raise WizardError(
|
|
683
|
-
f"role {role!r} model selection is out of order at ordinal {ordinal}"
|
|
684
|
-
)
|
|
685
|
-
selected.append(value)
|
|
706
|
+
previous = state.role_models.get(role)
|
|
707
|
+
state.role_models[role] = [value]
|
|
686
708
|
try:
|
|
687
|
-
profile = _load_role_profile_for_state(state)
|
|
688
709
|
_validate_submitted_role_model(state, profile)
|
|
689
710
|
except WizardError:
|
|
690
|
-
|
|
711
|
+
if previous is None:
|
|
712
|
+
state.role_models.pop(role, None)
|
|
713
|
+
else:
|
|
714
|
+
state.role_models[role] = previous
|
|
691
715
|
raise
|
|
692
|
-
return f"role-model: {role}#
|
|
716
|
+
return f"role-model: {role}#1={value}"
|
|
693
717
|
|
|
694
718
|
|
|
695
719
|
def _reset_role_selection_from(state: WizardState, target_step: str) -> None:
|
|
720
|
+
"""편집 대상 step 부터 되감는다 — 그 step 의 역할과 그 뒤 역할의 답을 비운다."""
|
|
696
721
|
try:
|
|
697
722
|
target_index = state.role_selection_order.index(target_step)
|
|
698
723
|
except ValueError as exc:
|
|
699
724
|
raise WizardError(f"unknown role selection step: {target_step!r}") from exc
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
step_id.split(":", 1)[1]
|
|
703
|
-
for step_id in kept_ids
|
|
704
|
-
if step_id.startswith(("role-count:", "role-add:"))
|
|
725
|
+
removed_roles = {
|
|
726
|
+
_step_role(step_id) for step_id in state.role_selection_order[target_index:]
|
|
705
727
|
}
|
|
706
|
-
|
|
707
|
-
for step_id in kept_ids:
|
|
708
|
-
if not step_id.startswith("role-model:"):
|
|
709
|
-
continue
|
|
710
|
-
_, role, _ = step_id.split(":", 2)
|
|
711
|
-
kept_models[role] = kept_models.get(role, 0) + 1
|
|
712
|
-
state.role_counts = {
|
|
713
|
-
role: count
|
|
714
|
-
for role, count in state.role_counts.items()
|
|
715
|
-
if role in kept_counts
|
|
716
|
-
}
|
|
717
|
-
state.role_models = {
|
|
718
|
-
role: models[:kept_models[role]]
|
|
719
|
-
for role, models in state.role_models.items()
|
|
720
|
-
if kept_models.get(role, 0) > 0
|
|
721
|
-
}
|
|
722
|
-
removed_ids = set(state.role_selection_order[target_index:])
|
|
723
|
-
state.role_selection_order = kept_ids
|
|
724
|
-
state.answered = [
|
|
725
|
-
step_id for step_id in state.answered if step_id not in removed_ids
|
|
726
|
-
]
|
|
728
|
+
_drop_role_selection(state, removed_roles)
|
|
727
729
|
|
|
728
730
|
|
|
729
731
|
def _clear_role_selection(state: WizardState) -> None:
|
|
730
732
|
state.role_counts.clear()
|
|
731
733
|
state.role_models.clear()
|
|
734
|
+
state.role_models_custom.clear()
|
|
732
735
|
state.role_selection_order.clear()
|
|
733
736
|
state.answered = [
|
|
734
737
|
step_id
|