flight-alloc 0.0.1__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.
- flight_alloc-0.0.1.dist-info/METADATA +11 -0
- flight_alloc-0.0.1.dist-info/RECORD +71 -0
- flight_alloc-0.0.1.dist-info/WHEEL +5 -0
- flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
- flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/allocator/__init__.py +6 -0
- src/allocator/caps.py +513 -0
- src/allocator/eligibility.py +302 -0
- src/allocator/greedy_fallback.py +106 -0
- src/allocator/invariants.py +124 -0
- src/allocator/p2f_priority.py +142 -0
- src/allocator/pair_validation.py +108 -0
- src/allocator/pairings.py +554 -0
- src/allocator/postpass_break.py +366 -0
- src/allocator/postpass_intl.py +483 -0
- src/allocator/postpass_p2f.py +723 -0
- src/allocator/postpass_rebalance.py +244 -0
- src/allocator/postsolve.py +549 -0
- src/allocator/recommender.py +348 -0
- src/allocator/windows.py +377 -0
- src/cli.py +41 -0
- src/config.py +168 -0
- src/greedy_fallback.py +102 -0
- src/io/__init__.py +0 -0
- src/io/export.py +270 -0
- src/io/export_xml.py +66 -0
- src/io/readers.py +1048 -0
- src/io/roster_library.py +89 -0
- src/plan.py +192 -0
- src/recommender_staffing.py +329 -0
- src/roster_store.py +159 -0
- src/schemas.py +1244 -0
- src/solver/__init__.py +0 -0
- src/solver/allocator_cpsat.py +1412 -0
- src/staged_overrides.py +468 -0
- src/state.py +494 -0
- src/step1_clean_flights.py +286 -0
- src/step2_extract_roster.py +316 -0
- src/step3_allocate_flights.py +1639 -0
- src/web/__init__.py +47 -0
- src/web/__main__.py +9 -0
- src/web/api/__init__.py +56 -0
- src/web/api/export.py +37 -0
- src/web/api/inputs.py +122 -0
- src/web/api/override_rows.py +138 -0
- src/web/api/pages.py +30 -0
- src/web/api/readbacks.py +72 -0
- src/web/api/recommender.py +72 -0
- src/web/api/runs.py +102 -0
- src/web/api/settings.py +201 -0
- src/web/api/zc.py +117 -0
- src/web/core/__init__.py +5 -0
- src/web/core/responses.py +91 -0
- src/web/core/router.py +167 -0
- src/web/core/static_files.py +85 -0
- src/web/overrides/__init__.py +66 -0
- src/web/overrides/airports.py +261 -0
- src/web/overrides/break_time.py +83 -0
- src/web/overrides/config_yaml.py +21 -0
- src/web/overrides/filters.py +187 -0
- src/web/overrides/rows.py +110 -0
- src/web/readback/__init__.py +67 -0
- src/web/readback/common.py +68 -0
- src/web/readback/dashboard.py +83 -0
- src/web/readback/planning.py +335 -0
- src/web/readback/session.py +158 -0
- src/web/readback/tables.py +163 -0
- src/web/runner.py +168 -0
- src/web/server.py +185 -0
- src/zc_store.py +221 -0
src/schemas.py
ADDED
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
"""Pydantic schemas for the flight-allocation pipeline (plan §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date as date_t
|
|
6
|
+
from datetime import datetime as datetime_t
|
|
7
|
+
from datetime import time as time_t
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any, Literal, Self
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CrewStatus(StrEnum):
|
|
15
|
+
"""Plan §4.1 — every legal value a roster cell may carry."""
|
|
16
|
+
|
|
17
|
+
UNDERSTUDY = "U"
|
|
18
|
+
DAY_OFF = "F"
|
|
19
|
+
PAID_LEAVE = "P/L"
|
|
20
|
+
CASUAL_LEAVE = "C/L"
|
|
21
|
+
CUSTOM_OFF = "C/OFF"
|
|
22
|
+
TRAINING = "TR"
|
|
23
|
+
SHIFT_M = "M"
|
|
24
|
+
SHIFT_A = "A"
|
|
25
|
+
SHIFT_N = "N"
|
|
26
|
+
SHIFT_M1 = "M1"
|
|
27
|
+
SHIFT_A1 = "A1"
|
|
28
|
+
AM_M_IGT = "M/IGT"
|
|
29
|
+
AM_A_IGT = "A/IGT"
|
|
30
|
+
ZC_M = "M/ZC"
|
|
31
|
+
ZC_A = "A/ZC"
|
|
32
|
+
ZC_N = "N/ZC"
|
|
33
|
+
ZC_M1 = "M1/ZC"
|
|
34
|
+
ZC_A1 = "A1/ZC"
|
|
35
|
+
# Per user direction 2026-05-10: roster can directly mark a staff
|
|
36
|
+
# member as the P2F handler for a shift. Cell value `M/P2F` means
|
|
37
|
+
# "on M shift today AND the P2F handler for M". Engine auto-picks
|
|
38
|
+
# without needing an override row.
|
|
39
|
+
# 2026-05-21 (user direction): canonical form is shift-first then
|
|
40
|
+
# role-suffix — M/P2F, A/P2F, N/P2F — matching the M/ZC, A/ZC,
|
|
41
|
+
# N/ZC convention. Legacy P2F/M / P2F/A / P2F/N still accepted via
|
|
42
|
+
# the canonicalization path in readers.normalize_status (parts
|
|
43
|
+
# sorted alphabetically — M < P2F, A < P2F, N < P2F).
|
|
44
|
+
P2F_M = "M/P2F"
|
|
45
|
+
P2F_A = "A/P2F"
|
|
46
|
+
P2F_N = "N/P2F"
|
|
47
|
+
# 2026-05-26 (user direction): a single roster cell can carry BOTH
|
|
48
|
+
# roles. `M/ZC/P2F` (or `M/P2F/ZC`, any order) marks the staff as
|
|
49
|
+
# the M-shift ZC AND the M-shift P2F handler. Canonical form is
|
|
50
|
+
# alphabetical: M < P2F < ZC. Cap stays at ZC's lower band (14-15
|
|
51
|
+
# day / 10-11 night), applied to regular+P2F combined.
|
|
52
|
+
ZC_P2F_M = "M/P2F/ZC"
|
|
53
|
+
ZC_P2F_A = "A/P2F/ZC"
|
|
54
|
+
ZC_P2F_N = "N/P2F/ZC"
|
|
55
|
+
ZC_P2F_M1 = "M1/P2F/ZC"
|
|
56
|
+
ZC_P2F_A1 = "A1/P2F/ZC"
|
|
57
|
+
OTHER = "OTHER"
|
|
58
|
+
BLANK = ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
NON_ASSIGNABLE: frozenset[CrewStatus] = frozenset({
|
|
62
|
+
CrewStatus.UNDERSTUDY,
|
|
63
|
+
CrewStatus.TRAINING,
|
|
64
|
+
CrewStatus.PAID_LEAVE,
|
|
65
|
+
CrewStatus.CASUAL_LEAVE,
|
|
66
|
+
CrewStatus.CUSTOM_OFF,
|
|
67
|
+
CrewStatus.OTHER,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class OpsClass(StrEnum):
|
|
72
|
+
"""Sheet routing for the cleaned flights output (plan §4.13).
|
|
73
|
+
|
|
74
|
+
Per user direction 2026-05-11:
|
|
75
|
+
- TEST: TYPE=X AND dep==arr (same-airport loop)
|
|
76
|
+
- FERRY: TYPE=X AND dep!=arr (positioning leg) — also TYPE in K/P/T/G
|
|
77
|
+
(legacy ferry mapping)
|
|
78
|
+
- CHARTER: TYPE=B (charter passenger flight, new class)
|
|
79
|
+
- All three are allocated as normal flights and counted under
|
|
80
|
+
workload (no special per-staff cap). The cleaned output groups
|
|
81
|
+
them into a single SpecialOps group with one table
|
|
82
|
+
per class so the assigner sees them side by side.
|
|
83
|
+
|
|
84
|
+
Per user direction 2026-05-19:
|
|
85
|
+
- GULF: flights touching AUH / DOH / DXB (DEP-side) OR with
|
|
86
|
+
Aircraft Owner=QR. EXTRACT-ONLY — visible on the dedicated
|
|
87
|
+
GULF class for reference, but Step 3 does NOT read
|
|
88
|
+
that sheet so these flights are NEVER allocated to staff. Wins
|
|
89
|
+
over the legacy ``routing_drop_dep_codes`` outright drop.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
DAY = "day"
|
|
93
|
+
NIGHT = "night"
|
|
94
|
+
P2F = "p2f"
|
|
95
|
+
FERRY = "ferry"
|
|
96
|
+
TEST = "test"
|
|
97
|
+
CHARTER = "charter"
|
|
98
|
+
GULF = "gulf"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RawFlightRow(BaseModel):
|
|
102
|
+
"""Raw SV portal row — permissive (plan §4.3 / §1.5.1).
|
|
103
|
+
|
|
104
|
+
Strict pax parsing is moved to Step 1's drop filter. Unknown columns are
|
|
105
|
+
ignored.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(extra="ignore")
|
|
109
|
+
|
|
110
|
+
flight_id: str | None = None
|
|
111
|
+
departure: str | None = None
|
|
112
|
+
arrival: str | None = None
|
|
113
|
+
dep_time: time_t | None = None
|
|
114
|
+
arr_time: time_t | None = None
|
|
115
|
+
aircraft_type: str | None = None
|
|
116
|
+
aircraft_subtype: str | None = None
|
|
117
|
+
# Aircraft Owner carrier code. Used for GULF owner routing and the
|
|
118
|
+
# extraction filters; everything else routes by TYPE / date.
|
|
119
|
+
owner: str | None = None
|
|
120
|
+
date: date_t | None = None
|
|
121
|
+
booked_pax_raw: str | None = None
|
|
122
|
+
# 2026-05-25 (user direction — extraction filters): capture every
|
|
123
|
+
# SV-portal cell by its raw header so the extraction-filter system
|
|
124
|
+
# can match on arbitrary columns (e.g. Crew, REG, MEMO) that
|
|
125
|
+
# aren't part of the canonical schema. Populated by
|
|
126
|
+
# ``read_sv_portal_from_workbook``; keys are the literal header
|
|
127
|
+
# strings, values are stringified cell contents (or None).
|
|
128
|
+
extra_columns: dict[str, str] = Field(default_factory=dict)
|
|
129
|
+
|
|
130
|
+
@field_validator(
|
|
131
|
+
"flight_id", "aircraft_subtype", "owner", "booked_pax_raw",
|
|
132
|
+
mode="before",
|
|
133
|
+
)
|
|
134
|
+
@classmethod
|
|
135
|
+
def _coerce_to_str(cls, v: Any) -> str | None:
|
|
136
|
+
if v is None:
|
|
137
|
+
return None
|
|
138
|
+
return str(v)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class CleanFlightRow(BaseModel):
|
|
142
|
+
"""Cleaned flight row written to one of the day_ops/night_ops/... sheets.
|
|
143
|
+
|
|
144
|
+
Phase 3 (2026-05-14, INTL overhaul):
|
|
145
|
+
- ``is_international``: **DEP-side only** — ``dep ∈ intl_codes``
|
|
146
|
+
(was ``dep OR arr``). Rendered yellow in workbook + browser.
|
|
147
|
+
- The legacy ``routing_via_excluded_hub`` field (pink-highlight for
|
|
148
|
+
Gulf-3 ARR) was removed; Gulf-3 ARR flights are plain domestic now.
|
|
149
|
+
Gulf-3 DEP flights are dropped at Step 1.
|
|
150
|
+
- ``is_preplan_deferred``: D+1 flights with STD ∈ [05:05, 05:30]
|
|
151
|
+
enter the pool but are NOT allocated today; Step 3 re-appends
|
|
152
|
+
them with warning="PREPLAN_DEFERRED".
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(frozen=True)
|
|
156
|
+
|
|
157
|
+
date: date_t
|
|
158
|
+
flt: str
|
|
159
|
+
type: str
|
|
160
|
+
ac: str
|
|
161
|
+
dep: str
|
|
162
|
+
arr: str
|
|
163
|
+
std: time_t
|
|
164
|
+
load: int = Field(ge=0, le=600)
|
|
165
|
+
ops_class: OpsClass
|
|
166
|
+
is_international: bool = False
|
|
167
|
+
is_preplan_deferred: bool = False
|
|
168
|
+
|
|
169
|
+
@field_validator("dep", "arr")
|
|
170
|
+
@classmethod
|
|
171
|
+
def _three_letters(cls, v: str) -> str:
|
|
172
|
+
v = v.upper()
|
|
173
|
+
if len(v) != 3 or not v.isalpha():
|
|
174
|
+
raise ValueError(f"expected 3 letters, got {v!r}")
|
|
175
|
+
return v
|
|
176
|
+
|
|
177
|
+
@field_validator("flt")
|
|
178
|
+
@classmethod
|
|
179
|
+
def _flt_non_empty(cls, v: str) -> str:
|
|
180
|
+
if not v:
|
|
181
|
+
raise ValueError("flt must be non-empty")
|
|
182
|
+
return v
|
|
183
|
+
|
|
184
|
+
@field_validator("type")
|
|
185
|
+
@classmethod
|
|
186
|
+
def _type_upper(cls, v: str) -> str:
|
|
187
|
+
# 2026-05-14: simplified SV portal exports drop the J/B/K/X letter
|
|
188
|
+
# column; ``type`` is allowed to be empty in that case. Ops_class
|
|
189
|
+
# then falls back to date-based DAY/NIGHT classification (see
|
|
190
|
+
# _derive_ops_class). Older exports that DO publish the letter
|
|
191
|
+
# column still benefit from the explicit per-letter mapping.
|
|
192
|
+
return v.upper()
|
|
193
|
+
|
|
194
|
+
@field_validator("ac")
|
|
195
|
+
@classmethod
|
|
196
|
+
def _ac_upper_lenient(cls, v: str) -> str:
|
|
197
|
+
# Real-world SV exports often leave AC blank; tolerate it.
|
|
198
|
+
return v.upper() if v else ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------- Phase 2: roster extraction ----------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Role(StrEnum):
|
|
205
|
+
"""Plan §4.5/§4.6 — STAFF for staff_roster.xlsx; AM/ZC inferred for am_roster.xlsx."""
|
|
206
|
+
|
|
207
|
+
STAFF = "STAFF"
|
|
208
|
+
AM = "AM"
|
|
209
|
+
ZC = "ZC"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
ShiftCode = Literal["M", "A", "N", "M1", "A1"]
|
|
213
|
+
"""The five plain shift codes that populate AvailabilityRow.current_shift."""
|
|
214
|
+
|
|
215
|
+
SHIFT_CODES: frozenset[str] = frozenset({"M", "A", "N", "M1", "A1"})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ============================================================================
|
|
219
|
+
# CENTRAL STATUS TAXONOMY (2026-05-27)
|
|
220
|
+
# ----------------------------------------------------------------------------
|
|
221
|
+
# Single source of truth for what each CrewStatus implies. Every reader,
|
|
222
|
+
# writer, dashboard panel, eligibility filter that needs to ask "what
|
|
223
|
+
# shift does this status mean?" / "does this status make them a P2F
|
|
224
|
+
# handler?" / "does this status imply ZC role?" should look up here —
|
|
225
|
+
# NOT carry its own copy of `if status in (...)`.
|
|
226
|
+
#
|
|
227
|
+
# WHEN ADDING A NEW STATUS:
|
|
228
|
+
# 1. Add the enum member to CrewStatus above.
|
|
229
|
+
# 2. Add entries to the maps below as appropriate.
|
|
230
|
+
# 3. That's it — readback, step2 emit, step3 admit, and the eligibility
|
|
231
|
+
# layer all pull from these maps, so they pick it up automatically.
|
|
232
|
+
# ============================================================================
|
|
233
|
+
|
|
234
|
+
# Status → shift it implies. None means the status doesn't carry a
|
|
235
|
+
# shift (off-day, leave, training, OTHER, BLANK).
|
|
236
|
+
STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode | None] = {
|
|
237
|
+
# Off / non-assignable
|
|
238
|
+
CrewStatus.UNDERSTUDY: None,
|
|
239
|
+
CrewStatus.DAY_OFF: None,
|
|
240
|
+
CrewStatus.PAID_LEAVE: None,
|
|
241
|
+
CrewStatus.CASUAL_LEAVE: None,
|
|
242
|
+
CrewStatus.CUSTOM_OFF: None,
|
|
243
|
+
CrewStatus.TRAINING: None,
|
|
244
|
+
CrewStatus.OTHER: None,
|
|
245
|
+
CrewStatus.BLANK: None,
|
|
246
|
+
# Plain shift codes
|
|
247
|
+
CrewStatus.SHIFT_M: "M",
|
|
248
|
+
CrewStatus.SHIFT_A: "A",
|
|
249
|
+
CrewStatus.SHIFT_N: "N",
|
|
250
|
+
CrewStatus.SHIFT_M1: "M1",
|
|
251
|
+
CrewStatus.SHIFT_A1: "A1",
|
|
252
|
+
# AM (on-shift but not flying — admin/training duty)
|
|
253
|
+
CrewStatus.AM_M_IGT: "M",
|
|
254
|
+
CrewStatus.AM_A_IGT: "A",
|
|
255
|
+
# ZC variants
|
|
256
|
+
CrewStatus.ZC_M: "M",
|
|
257
|
+
CrewStatus.ZC_A: "A",
|
|
258
|
+
CrewStatus.ZC_N: "N",
|
|
259
|
+
CrewStatus.ZC_M1: "M1",
|
|
260
|
+
CrewStatus.ZC_A1: "A1",
|
|
261
|
+
# P2F-handler variants (STAFF that nominate as P2F handler)
|
|
262
|
+
CrewStatus.P2F_M: "M",
|
|
263
|
+
CrewStatus.P2F_A: "A",
|
|
264
|
+
CrewStatus.P2F_N: "N",
|
|
265
|
+
# Combined ZC + P2F-handler variants
|
|
266
|
+
CrewStatus.ZC_P2F_M: "M",
|
|
267
|
+
CrewStatus.ZC_P2F_A: "A",
|
|
268
|
+
CrewStatus.ZC_P2F_N: "N",
|
|
269
|
+
CrewStatus.ZC_P2F_M1: "M1",
|
|
270
|
+
CrewStatus.ZC_P2F_A1: "A1",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Statuses that explicitly imply a particular Role. Statuses NOT in
|
|
274
|
+
# this set inherit role from the crew row's sheet-origin (STAFF for
|
|
275
|
+
# IN_Staff, AM for IN_AM_Roster). Resolved via STATUS_TO_ROLE() below
|
|
276
|
+
# because Role isn't defined yet at module-load time when this dict
|
|
277
|
+
# is built — we close over the Role enum lazily inside the helper.
|
|
278
|
+
_STATUS_ROLE_TAGS: dict[CrewStatus, str] = {
|
|
279
|
+
# ZC variants imply ZC role for the day (overrides crew-row default).
|
|
280
|
+
CrewStatus.ZC_M: "ZC",
|
|
281
|
+
CrewStatus.ZC_A: "ZC",
|
|
282
|
+
CrewStatus.ZC_N: "ZC",
|
|
283
|
+
CrewStatus.ZC_M1: "ZC",
|
|
284
|
+
CrewStatus.ZC_A1: "ZC",
|
|
285
|
+
CrewStatus.ZC_P2F_M: "ZC",
|
|
286
|
+
CrewStatus.ZC_P2F_A: "ZC",
|
|
287
|
+
CrewStatus.ZC_P2F_N: "ZC",
|
|
288
|
+
CrewStatus.ZC_P2F_M1: "ZC",
|
|
289
|
+
CrewStatus.ZC_P2F_A1: "ZC",
|
|
290
|
+
# AM/IGT variants → AM
|
|
291
|
+
CrewStatus.AM_M_IGT: "AM",
|
|
292
|
+
CrewStatus.AM_A_IGT: "AM",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# Statuses that imply ZC role for the day (subset of _STATUS_ROLE_TAGS).
|
|
296
|
+
STATUS_IS_ZC: frozenset[CrewStatus] = frozenset(
|
|
297
|
+
s for s, tag in _STATUS_ROLE_TAGS.items() if tag == "ZC"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Statuses that nominate the staff as a P2F handler. Maps to the
|
|
301
|
+
# SHIFT they handle P2F flights for (which equals STATUS_TO_SHIFT[s]
|
|
302
|
+
# for these — but kept as a separate map for clarity at call sites).
|
|
303
|
+
STATUS_TO_P2F_HANDLER_SHIFT: dict[CrewStatus, ShiftCode] = {
|
|
304
|
+
CrewStatus.P2F_M: "M",
|
|
305
|
+
CrewStatus.P2F_A: "A",
|
|
306
|
+
CrewStatus.P2F_N: "N",
|
|
307
|
+
CrewStatus.ZC_P2F_M: "M",
|
|
308
|
+
CrewStatus.ZC_P2F_A: "A",
|
|
309
|
+
CrewStatus.ZC_P2F_N: "N",
|
|
310
|
+
CrewStatus.ZC_P2F_M1: "M1",
|
|
311
|
+
CrewStatus.ZC_P2F_A1: "A1",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# Convenience set form for `status in STATUS_IS_P2F_HANDLER` checks.
|
|
315
|
+
STATUS_IS_P2F_HANDLER: frozenset[CrewStatus] = frozenset(
|
|
316
|
+
STATUS_TO_P2F_HANDLER_SHIFT.keys()
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def role_implied_by_status(status: CrewStatus) -> "Role | None":
|
|
321
|
+
"""Return the Role this status forces for the day, or None when
|
|
322
|
+
the status doesn't override (caller falls back to the crew row's
|
|
323
|
+
sheet-origin role). Lazy lookup of Role to dodge the forward
|
|
324
|
+
reference — Role is defined after this section."""
|
|
325
|
+
tag = _STATUS_ROLE_TAGS.get(status)
|
|
326
|
+
if tag is None:
|
|
327
|
+
return None
|
|
328
|
+
return Role(tag)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def is_p2f_handler_status(status: CrewStatus) -> bool:
|
|
332
|
+
"""True iff the status nominates the staff as a P2F handler."""
|
|
333
|
+
return status in STATUS_IS_P2F_HANDLER
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def p2f_handler_shift(status: CrewStatus) -> ShiftCode | None:
|
|
337
|
+
"""Return the shift this status nominates a P2F handler for, or
|
|
338
|
+
None if the status isn't a P2F-handler status."""
|
|
339
|
+
return STATUS_TO_P2F_HANDLER_SHIFT.get(status)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def is_zc_status(status: CrewStatus) -> bool:
|
|
343
|
+
"""True iff the status implies ZC role for the day."""
|
|
344
|
+
return status in STATUS_IS_ZC
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def shift_of_status(status: CrewStatus) -> ShiftCode | None:
|
|
348
|
+
"""Return the shift this status implies, or None for off-day /
|
|
349
|
+
leave / OTHER / BLANK."""
|
|
350
|
+
return STATUS_TO_SHIFT.get(status)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# Convenience: statuses that auto-grant a P2F license (because they
|
|
354
|
+
# nominate the staff as a P2F handler). step2 uses this to populate
|
|
355
|
+
# the license cell so downstream eligibility (F3) treats them as
|
|
356
|
+
# licensed even when the License column on the input roster was blank.
|
|
357
|
+
P2F_LICENSE_GRANTING_STATUSES: frozenset[CrewStatus] = STATUS_IS_P2F_HANDLER
|
|
358
|
+
|
|
359
|
+
# Shifts that nominate a plain (non-ZC) P2F handler. M1 / A1 do NOT
|
|
360
|
+
# nominate plain P2F handlers per the 2026-05-10 directive — only
|
|
361
|
+
# M / A / N. The combined ZC+P2F variant CAN appear on M1 / A1 via
|
|
362
|
+
# the ZC_P2F_M1 / ZC_P2F_A1 statuses; those route through
|
|
363
|
+
# STATUS_TO_P2F_HANDLER_SHIFT regardless of this tuple.
|
|
364
|
+
P2F_HANDLER_ELIGIBLE_SHIFTS: tuple[ShiftCode, ...] = ("M", "A", "N")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class OverrideType(StrEnum):
|
|
368
|
+
"""Every legal `type` value an override row may carry.
|
|
369
|
+
|
|
370
|
+
Adding a new override type? Three steps:
|
|
371
|
+
1. Add an enum member here.
|
|
372
|
+
2. Implement the reader/applier (typically in io/readers.py or
|
|
373
|
+
staged_overrides.py).
|
|
374
|
+
3. If the assigner UI should offer it in the drawer's Add row
|
|
375
|
+
dropdown, add to OVERRIDE_TYPES_FOR_UI (below). Frontend
|
|
376
|
+
pulls the list from /api/override_types at page load, so a
|
|
377
|
+
new entry shows up automatically.
|
|
378
|
+
"""
|
|
379
|
+
# Handler nominations (engine reads at solve time)
|
|
380
|
+
P2F = "p2f"
|
|
381
|
+
# Per-staff constraints (read at solve time)
|
|
382
|
+
SICK = "sick"
|
|
383
|
+
CHANGE_ROLE = "change_role"
|
|
384
|
+
CHANGE_SHIFT = "change_shift"
|
|
385
|
+
MAX_FLIGHTS = "max_flights"
|
|
386
|
+
CUTOFF_TIME = "cutoff_time"
|
|
387
|
+
# Staged-form mutations (apply directly to data sheets at save time)
|
|
388
|
+
ADD_FLIGHT = "add_flight"
|
|
389
|
+
REMOVE_FLIGHT = "remove_flight"
|
|
390
|
+
ADD_STAFF = "add_staff"
|
|
391
|
+
REMOVE_STAFF = "remove_staff"
|
|
392
|
+
# Phase-R per-flight overrides written by the recommender
|
|
393
|
+
WAIVE_H10_PAIR = "waive_h10_pair"
|
|
394
|
+
RAISE_CAP = "raise_cap"
|
|
395
|
+
SKIP_P2F_BUFFER = "skip_p2f_buffer"
|
|
396
|
+
SKIP_INTL_REMOVAL = "skip_intl_removal"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# The subset that the drawer's Add row dropdown should expose — i.e.,
|
|
400
|
+
# user-facing override types. Phase-R recommender types are written
|
|
401
|
+
# via /api/recommender/apply, not the manual drawer flow, so they're
|
|
402
|
+
# excluded here.
|
|
403
|
+
OVERRIDE_TYPES_FOR_UI: tuple[OverrideType, ...] = (
|
|
404
|
+
OverrideType.P2F,
|
|
405
|
+
OverrideType.SICK,
|
|
406
|
+
OverrideType.CHANGE_ROLE,
|
|
407
|
+
OverrideType.CHANGE_SHIFT,
|
|
408
|
+
OverrideType.MAX_FLIGHTS,
|
|
409
|
+
OverrideType.CUTOFF_TIME,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# Subset that the staged-form helper accepts (add/remove flight/staff,
|
|
414
|
+
# the Roster Change pane's shift moves, and the Handler Assign pane's
|
|
415
|
+
# P2F nominations — all apply immediately, no drawer round-trip).
|
|
416
|
+
STAGED_FORM_OVERRIDE_TYPES: tuple[OverrideType, ...] = (
|
|
417
|
+
OverrideType.ADD_FLIGHT,
|
|
418
|
+
OverrideType.REMOVE_FLIGHT,
|
|
419
|
+
OverrideType.ADD_STAFF,
|
|
420
|
+
OverrideType.REMOVE_STAFF,
|
|
421
|
+
OverrideType.CHANGE_SHIFT,
|
|
422
|
+
OverrideType.P2F,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# 2026-05-27 (user direction — "no patchwork, centralize"): the drawer
|
|
427
|
+
# was rendering every override column on every row regardless of
|
|
428
|
+
# type, so a p2f row showed empty std / other_std / dep / arr cells
|
|
429
|
+
# (added by Phase-R writes) crowding out the cells that actually
|
|
430
|
+
# matter. This map declares, per override type, which override
|
|
431
|
+
# header columns are relevant — the frontend uses it to gray-out
|
|
432
|
+
# irrelevant cells (so the assigner only sees the dropdowns that
|
|
433
|
+
# matter for the row's type).
|
|
434
|
+
#
|
|
435
|
+
# Adding a new OverrideType? Add its column set here so the drawer
|
|
436
|
+
# auto-tailors. The frontend pulls this via /api/override_types.
|
|
437
|
+
#
|
|
438
|
+
# Names are LOWERCASED matches against the override column names.
|
|
439
|
+
# Headers not listed here for a given type are treated as irrelevant
|
|
440
|
+
# and rendered as a disabled "—" placeholder (the actual cell value
|
|
441
|
+
# is preserved on save — we only hide it visually).
|
|
442
|
+
OVERRIDE_TYPE_RELEVANT_COLS: dict[OverrideType, tuple[str, ...]] = {
|
|
443
|
+
# ----- User-facing (handler nominations + per-staff) -----
|
|
444
|
+
OverrideType.P2F: ("type", "employee", "shift"),
|
|
445
|
+
OverrideType.SICK: ("type", "employee"),
|
|
446
|
+
# change_role: the "shift" column carries the new ROLE
|
|
447
|
+
# (STAFF / ZC / AM), not a shift code — the frontend swaps the
|
|
448
|
+
# dropdown's option set when type=change_role.
|
|
449
|
+
OverrideType.CHANGE_ROLE: ("type", "employee", "shift"),
|
|
450
|
+
# change_shift: the "shift" column carries the new CrewStatus literal
|
|
451
|
+
# for an EXISTING staff member — a plain shift code (M/A/N/M1/A1) to
|
|
452
|
+
# move them, or an off/leave code (F day-off, P/L paid leave, C/L
|
|
453
|
+
# casual leave, C/OFF custom off) to pull them off today without
|
|
454
|
+
# dropping their roster row. Roster Change pane on the dashboard.
|
|
455
|
+
OverrideType.CHANGE_SHIFT: ("type", "employee", "shift"),
|
|
456
|
+
OverrideType.MAX_FLIGHTS: ("type", "employee", "limit"),
|
|
457
|
+
# cutoff_time: windowed (from_time + to_time). Either may be blank;
|
|
458
|
+
# the engine falls back to the staff's normal shift boundary.
|
|
459
|
+
OverrideType.CUTOFF_TIME: ("type", "employee", "from_time", "to_time"),
|
|
460
|
+
# ----- Staged forms (add/remove flight/staff) -----
|
|
461
|
+
OverrideType.ADD_FLIGHT: (
|
|
462
|
+
"type", "flt", "dep", "arr", "std",
|
|
463
|
+
"ops_class", "ac_type", "pax", "owner", "date",
|
|
464
|
+
),
|
|
465
|
+
OverrideType.REMOVE_FLIGHT: ("type", "flt", "dep", "arr", "std", "date"),
|
|
466
|
+
OverrideType.ADD_STAFF: ("type", "employee", "shift", "role"),
|
|
467
|
+
OverrideType.REMOVE_STAFF: ("type", "employee"),
|
|
468
|
+
# ----- Phase-R recommender types -----
|
|
469
|
+
OverrideType.WAIVE_H10_PAIR: ("type", "employee", "flight", "std", "other_std"),
|
|
470
|
+
OverrideType.RAISE_CAP: ("type", "employee", "shift", "limit", "flight", "std"),
|
|
471
|
+
OverrideType.SKIP_P2F_BUFFER: ("type", "employee", "flight", "std"),
|
|
472
|
+
OverrideType.SKIP_INTL_REMOVAL: ("type", "flight", "std"),
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _validate_override_relevant_cols_completeness() -> None:
|
|
477
|
+
"""Fail at import time if a new OverrideType is added but never
|
|
478
|
+
registered in OVERRIDE_TYPE_RELEVANT_COLS — same defensive pattern
|
|
479
|
+
as _validate_taxonomy_completeness for CrewStatus. Catches the
|
|
480
|
+
"added enum, forgot the map" regression before it ships."""
|
|
481
|
+
missing = [t for t in OverrideType if t not in OVERRIDE_TYPE_RELEVANT_COLS]
|
|
482
|
+
if missing:
|
|
483
|
+
raise RuntimeError(
|
|
484
|
+
"OverrideType relevant-cols map incomplete — schemas."
|
|
485
|
+
"OVERRIDE_TYPE_RELEVANT_COLS missing entries for: "
|
|
486
|
+
f"{[m.value for m in missing]}. Add each new type's "
|
|
487
|
+
"tuple of relevant header names so the drawer renders "
|
|
488
|
+
"the right dropdowns and gray-outs the rest."
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
_validate_override_relevant_cols_completeness()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _validate_taxonomy_completeness() -> None:
|
|
496
|
+
"""Fail loudly at import time if a new CrewStatus member was added
|
|
497
|
+
but never registered in STATUS_TO_SHIFT.
|
|
498
|
+
|
|
499
|
+
This catches the most common "I added a new enum value but forgot
|
|
500
|
+
to update the map" regression — the same class of bug that's
|
|
501
|
+
bitten the engine multiple times in 2026-05. Better to crash the
|
|
502
|
+
server at startup with a precise message than to silently
|
|
503
|
+
misroute statuses in production.
|
|
504
|
+
"""
|
|
505
|
+
missing = [s for s in CrewStatus if s not in STATUS_TO_SHIFT]
|
|
506
|
+
if missing:
|
|
507
|
+
raise RuntimeError(
|
|
508
|
+
"CrewStatus taxonomy incomplete in schemas.STATUS_TO_SHIFT — "
|
|
509
|
+
f"missing entries: {[s.value for s in missing]}. "
|
|
510
|
+
"Every CrewStatus enum member must be registered in "
|
|
511
|
+
"STATUS_TO_SHIFT (use None for off-day / leave / OTHER). "
|
|
512
|
+
"Edit src/schemas.py to fix."
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
_validate_taxonomy_completeness()
|
|
517
|
+
# ============================================================================
|
|
518
|
+
# END central status taxonomy
|
|
519
|
+
# ============================================================================
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class CrewRosterRow(BaseModel):
|
|
523
|
+
"""Wide crew-roster row read from IN_Staff (plan §4.5).
|
|
524
|
+
|
|
525
|
+
Normalization happens at read time: ``status_by_date`` holds CrewStatus values
|
|
526
|
+
(with ``OFF→F`` aliases applied and unrecognized literals mapped to OTHER).
|
|
527
|
+
The original literal is preserved in ``raw_status_by_date`` per §1.5.1.
|
|
528
|
+
|
|
529
|
+
Step 4 additions:
|
|
530
|
+
- ``employee_id``: stable join key, auto-generated as ``STAFF_NNN`` by
|
|
531
|
+
the reader if the assigner leaves the IN_Staff cell blank. Disambiguates
|
|
532
|
+
same-name records (e.g., the two DEEPAK KUMARs across IN_Staff and
|
|
533
|
+
IN_AM_Roster).
|
|
534
|
+
- ``license``: P2F-licensure tag. None = no special license. Values
|
|
535
|
+
observed in real data: 'P2F', 'P2F+Corendon', 'Corendon'.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
model_config = ConfigDict(frozen=True)
|
|
539
|
+
employee_id: str
|
|
540
|
+
name: str
|
|
541
|
+
role: Role
|
|
542
|
+
license: str | None = None
|
|
543
|
+
status_by_date: dict[date_t, CrewStatus]
|
|
544
|
+
raw_status_by_date: dict[date_t, str]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class AMRosterRow(BaseModel):
|
|
548
|
+
"""Wide AM/ZC-roster row read from IN_AM_Roster (plan §4.6).
|
|
549
|
+
|
|
550
|
+
``role`` is inferred at read time:
|
|
551
|
+
- any ``M/IGT`` or ``A/IGT`` anywhere in the month -> Role.AM
|
|
552
|
+
- else any ``*/ZC`` -> Role.ZC
|
|
553
|
+
- else -> Role.STAFF (defensive fallback)
|
|
554
|
+
|
|
555
|
+
Step 4 additions: ``employee_id`` (auto-generated as ``AM_NNN`` by the
|
|
556
|
+
reader if blank) and ``license`` (per-row P2F tag — ZCs may also be P2F-
|
|
557
|
+
licensed and act as the shift's P2F handler).
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
model_config = ConfigDict(frozen=True)
|
|
561
|
+
employee_id: str
|
|
562
|
+
name: str
|
|
563
|
+
role: Role
|
|
564
|
+
license: str | None = None
|
|
565
|
+
status_by_date: dict[date_t, CrewStatus]
|
|
566
|
+
raw_status_by_date: dict[date_t, str]
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class AvailabilityRow(BaseModel):
|
|
570
|
+
"""Step 2 output, long format (plan §4.9 + §1.5.1).
|
|
571
|
+
|
|
572
|
+
Step 4 additions: ``employee_id`` and ``license`` propagate verbatim from
|
|
573
|
+
CrewRosterRow / AMRosterRow so downstream solvers can join by ID and
|
|
574
|
+
filter by license without re-reading the source sheets.
|
|
575
|
+
"""
|
|
576
|
+
|
|
577
|
+
model_config = ConfigDict(frozen=True)
|
|
578
|
+
employee_id: str
|
|
579
|
+
name: str
|
|
580
|
+
role: Role
|
|
581
|
+
date: date_t
|
|
582
|
+
status: CrewStatus
|
|
583
|
+
raw_status: str
|
|
584
|
+
assignable: bool
|
|
585
|
+
current_shift: ShiftCode | None = None
|
|
586
|
+
license: str | None = None
|
|
587
|
+
# Which roster the person comes from (STAFF for the staff roster, AM
|
|
588
|
+
# for the AM roster), before any /ZC promotion. Lets a ZC be taken
|
|
589
|
+
# back to what they were when the assigner removes them on the
|
|
590
|
+
# dashboard. None for synthetic rows (add_staff).
|
|
591
|
+
origin_role: Role | None = None
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class Severity(StrEnum):
|
|
595
|
+
INFO = "INFO"
|
|
596
|
+
WARN = "WARN"
|
|
597
|
+
ERROR = "ERROR"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
class WarningRow(BaseModel):
|
|
601
|
+
"""Plan §4.12."""
|
|
602
|
+
|
|
603
|
+
model_config = ConfigDict(frozen=True)
|
|
604
|
+
severity: Severity
|
|
605
|
+
code: str = Field(pattern=r"^W\d{3}$")
|
|
606
|
+
name: str | None = None
|
|
607
|
+
date: date_t | None = None
|
|
608
|
+
message: str = Field(max_length=500)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# ---------- Phase 3: balance roster ----------
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
class OverrideMode(StrEnum):
|
|
615
|
+
"""Plan §4.7 — disposition of a single Overrides row.
|
|
616
|
+
|
|
617
|
+
NO row is informational, ignored by solver
|
|
618
|
+
FORCE hard constraint: solver must assign to_shift on (employee, date);
|
|
619
|
+
infeasibility triggers W001 drop-and-retry
|
|
620
|
+
VOLUNTEER soft hint: employee has nominated themself for from_shift -> to_shift.
|
|
621
|
+
Solver MAY take the swap "for free" (no swap-count penalty) if that
|
|
622
|
+
helps balance, but never forces it. If the solver leaves the crew on
|
|
623
|
+
from_shift, the volunteered swap is silently unused.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
NO = "NO"
|
|
627
|
+
FORCE = "FORCE"
|
|
628
|
+
VOLUNTEER = "VOLUNTEER"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class OverrideRow(BaseModel):
|
|
632
|
+
"""Plan §4.7 — one row of the Overrides sheet."""
|
|
633
|
+
|
|
634
|
+
model_config = ConfigDict(frozen=True)
|
|
635
|
+
employee: str
|
|
636
|
+
date: date_t
|
|
637
|
+
mode: OverrideMode
|
|
638
|
+
from_shift: ShiftCode | None = None
|
|
639
|
+
to_shift: ShiftCode | None = None
|
|
640
|
+
|
|
641
|
+
@model_validator(mode="after")
|
|
642
|
+
def _check_active_modes_require_shifts(self) -> Self:
|
|
643
|
+
if self.mode != OverrideMode.NO and (self.from_shift is None or self.to_shift is None):
|
|
644
|
+
raise ValueError(f"mode={self.mode} requires both from_shift and to_shift")
|
|
645
|
+
return self
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# RequiredStaffingRow removed 2026-05-21 — the IN_Required sheet and
|
|
649
|
+
# its reader are gone, no callers reference this schema anymore.
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
class Origin(StrEnum):
|
|
653
|
+
ROSTER = "ROSTER"
|
|
654
|
+
OVERRIDE = "OVERRIDE"
|
|
655
|
+
SOLVER_SWAP = "SOLVER_SWAP"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
class BalancedRosterRow(BaseModel):
|
|
659
|
+
"""Plan §4.10 — Step 3 output, long format.
|
|
660
|
+
|
|
661
|
+
Step 4 additions: ``employee_id`` (the join key Step 4 uses everywhere) and
|
|
662
|
+
``license`` (passed through from AvailabilityRow).
|
|
663
|
+
"""
|
|
664
|
+
|
|
665
|
+
model_config = ConfigDict(frozen=True)
|
|
666
|
+
employee_id: str
|
|
667
|
+
name: str
|
|
668
|
+
role: Role
|
|
669
|
+
date: date_t
|
|
670
|
+
assigned_shift: ShiftCode | None = None
|
|
671
|
+
origin: Origin
|
|
672
|
+
was_swapped: bool
|
|
673
|
+
license: str | None = None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class SwapReason(StrEnum):
|
|
677
|
+
RESPECT_OVERRIDE = "RESPECT_OVERRIDE"
|
|
678
|
+
BALANCE_DEFICIT = "BALANCE_DEFICIT"
|
|
679
|
+
BALANCE_SURPLUS = "BALANCE_SURPLUS"
|
|
680
|
+
# Crew moved to OFF because keeping their current_shift would have violated the
|
|
681
|
+
# 24-hr rule against either D-1 history or the other target date's natural
|
|
682
|
+
# shift. Distinguishes a forced-off from a true surplus removal.
|
|
683
|
+
RULE_24H_CONFLICT = "RULE_24H_CONFLICT"
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class SwapLogRow(BaseModel):
|
|
687
|
+
"""Plan §4.11."""
|
|
688
|
+
|
|
689
|
+
model_config = ConfigDict(frozen=True)
|
|
690
|
+
name: str
|
|
691
|
+
date: date_t
|
|
692
|
+
from_shift: str | None = None
|
|
693
|
+
to_shift: str | None = None
|
|
694
|
+
reason: SwapReason
|
|
695
|
+
timestamp_utc: datetime_t
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# Plan §4.2 shift-start times (24-hr rule uses these as IntervalVar starts).
|
|
699
|
+
SHIFT_START: dict[str, time_t] = {
|
|
700
|
+
"M": time_t(4, 0),
|
|
701
|
+
"A": time_t(12, 30),
|
|
702
|
+
"N": time_t(20, 30),
|
|
703
|
+
"M1": time_t(6, 0),
|
|
704
|
+
"A1": time_t(14, 30),
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ====================================================================
|
|
709
|
+
# Phase 4: flight allocation
|
|
710
|
+
#
|
|
711
|
+
# These models are consumed only by Step 4. Earlier-phase code (Steps 1-3)
|
|
712
|
+
# does not import any of them. Keeping them in schemas.py rather than a
|
|
713
|
+
# step4-specific module so the type vocabulary stays in one place.
|
|
714
|
+
# ====================================================================
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class OpsBoundary(StrEnum):
|
|
718
|
+
"""A shift transition. Pairs are keyed by boundary; ZC pairs use the same
|
|
719
|
+
boundary identifiers."""
|
|
720
|
+
|
|
721
|
+
N_TO_M = "N->M"
|
|
722
|
+
M_TO_M1 = "M->M1"
|
|
723
|
+
M_TO_A = "M->A"
|
|
724
|
+
M1_TO_A1 = "M1->A1"
|
|
725
|
+
A_TO_N = "A->N"
|
|
726
|
+
A_TO_A1 = "A->A1" # handover-only routing for A surplus
|
|
727
|
+
A1_TO_N = "A1->N"
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class PairRole(StrEnum):
|
|
731
|
+
"""A pair's responsibility at its boundary.
|
|
732
|
+
|
|
733
|
+
PRIMARY — both pre-planning (per H6 counts) AND tail handover
|
|
734
|
+
(per H7 windows).
|
|
735
|
+
HANDOVER_ONLY — tail handover only; never carries pre-planning.
|
|
736
|
+
Used for A↔A1 routing when |A|>|N| and for the
|
|
737
|
+
secondary A↔N pairs when surplus A staff still remain
|
|
738
|
+
after the A1 handover route is exhausted.
|
|
739
|
+
"""
|
|
740
|
+
|
|
741
|
+
PRIMARY = "primary"
|
|
742
|
+
HANDOVER_ONLY = "handover_only"
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
class AllocationSheet(StrEnum):
|
|
746
|
+
"""Routing target for an AllocationRow — which allocation lane
|
|
747
|
+
the row writes to."""
|
|
748
|
+
|
|
749
|
+
DAY_OPS = "DayOps"
|
|
750
|
+
NIGHT_OPS = "NightOps"
|
|
751
|
+
P2F = "P2F"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
class FlightInput(BaseModel):
|
|
755
|
+
"""A single flight for Step 4's solver input. Mirrors CleanFlightRow but
|
|
756
|
+
adds Step-4-specific tags computed during preprocessing."""
|
|
757
|
+
|
|
758
|
+
model_config = ConfigDict(frozen=True)
|
|
759
|
+
date: date_t
|
|
760
|
+
flt: str
|
|
761
|
+
type: str
|
|
762
|
+
ac: str
|
|
763
|
+
dep: str
|
|
764
|
+
arr: str
|
|
765
|
+
std: time_t
|
|
766
|
+
load: int = Field(ge=0, le=600)
|
|
767
|
+
ops_class: OpsClass
|
|
768
|
+
is_international: bool = False
|
|
769
|
+
is_preplan_deferred: bool = False
|
|
770
|
+
"""Phase 3 / Change 7: D+1 flights with STD ∈ [05:05, 05:30] enter
|
|
771
|
+
the pool but are split out by Step 3 before the solver runs; re-
|
|
772
|
+
appended to the output sheet with warning="PREPLAN_DEFERRED" and
|
|
773
|
+
empty staff column. Solver never sees them."""
|
|
774
|
+
is_awkward_window: bool = False # H15 routing
|
|
775
|
+
awkward_eligible_shifts: tuple[ShiftCode, ...] = ()
|
|
776
|
+
"""If is_awkward_window=True, the only shifts a flight at this STD may be
|
|
777
|
+
assigned to. Empty tuple when not in an awkward window."""
|
|
778
|
+
|
|
779
|
+
@property
|
|
780
|
+
def unique_id(self) -> str:
|
|
781
|
+
"""A stable, unique identifier for THIS leg of the rotation.
|
|
782
|
+
|
|
783
|
+
Per user direction 2026-05-12: multi-leg flights (same FLT number,
|
|
784
|
+
different STD/dep/arr legs) must be treated as SEPARATE flights for
|
|
785
|
+
allocation purposes. Earlier code keyed eligibility / x-vars by
|
|
786
|
+
``flt`` alone, which made later legs overwrite earlier ones and
|
|
787
|
+
caused phantom assignments + H10 spacing violations. Keying by
|
|
788
|
+
(flt, dep, arr, std, date) makes each leg an independent flight
|
|
789
|
+
in the CP-SAT model — exactly what the operations team expects.
|
|
790
|
+
|
|
791
|
+
2026-05-26 fix: date appended. SV portal exports two days at a
|
|
792
|
+
time; the same daily flight (e.g. 64 JED-DEL 05:30) appears on
|
|
793
|
+
both D and D+1 with identical FLT|DEP|ARR|STD. Without date in
|
|
794
|
+
the key, the D+1 preplan-deferred filter collided with D-day and
|
|
795
|
+
silently dropped 33 morning flights per run.
|
|
796
|
+
"""
|
|
797
|
+
return f"{self.flt}|{self.dep}|{self.arr}|{self.std.isoformat(timespec='minutes')}|{self.date.isoformat()}"
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class StaffMember(BaseModel):
|
|
801
|
+
"""A single staff record assembled by Step 4 from OUT_Balanced_Roster
|
|
802
|
+
(STAFF) or IN_AM_Roster (ZC). AMs are not represented as StaffMember at
|
|
803
|
+
all — they are filtered out at preprocessing time.
|
|
804
|
+
"""
|
|
805
|
+
|
|
806
|
+
model_config = ConfigDict(frozen=True)
|
|
807
|
+
employee_id: str
|
|
808
|
+
name: str
|
|
809
|
+
role: Role
|
|
810
|
+
date: date_t
|
|
811
|
+
shift_today: ShiftCode | None = None
|
|
812
|
+
"""None means the staff is OFF today (not assignable). Assignable staff
|
|
813
|
+
have one of M/A/N/M1/A1."""
|
|
814
|
+
license: str | None = None
|
|
815
|
+
is_p2f_licensed: bool = False
|
|
816
|
+
"""Convenience flag derived from license string. True when license
|
|
817
|
+
contains 'P2F'."""
|
|
818
|
+
is_newbie: bool = False
|
|
819
|
+
"""Per override row; affects H12 international exclusion."""
|
|
820
|
+
max_flights_cap: int | None = None
|
|
821
|
+
"""Per-staff override max; lower-bound clamp on the
|
|
822
|
+
H16 hard cap. None means use the H16 default for the staff's shift+role."""
|
|
823
|
+
std_cutoff: time_t | None = None
|
|
824
|
+
"""Per-staff override STD cutoff (UPPER bound);
|
|
825
|
+
flights with STD past this are excluded for this staff. None means
|
|
826
|
+
no upper bound — staff's normal shift end applies."""
|
|
827
|
+
std_start: time_t | None = None
|
|
828
|
+
"""2026-05-27: per-staff override STD START (LOWER bound) from
|
|
829
|
+
an override; flights with STD before this are excluded for this
|
|
830
|
+
staff. None means no lower bound — staff's normal shift start
|
|
831
|
+
applies. Together with std_cutoff, this carves a custom working
|
|
832
|
+
window inside the staff's normal shift. Example: an A-staff with
|
|
833
|
+
std_start=14:00 + std_cutoff=17:00 only takes flights in that
|
|
834
|
+
3-hour band; one with just std_cutoff=17:00 comes in at the
|
|
835
|
+
regular A start (12:30) but leaves early at 17:00."""
|
|
836
|
+
|
|
837
|
+
@model_validator(mode="after")
|
|
838
|
+
def _coherent_p2f_flag(self) -> Self:
|
|
839
|
+
if self.is_p2f_licensed and not (self.license and "P2F" in self.license.upper()):
|
|
840
|
+
raise ValueError(
|
|
841
|
+
"is_p2f_licensed=True requires 'P2F' in license; "
|
|
842
|
+
f"got license={self.license!r}"
|
|
843
|
+
)
|
|
844
|
+
return self
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class Pair(BaseModel):
|
|
848
|
+
"""One pair at one boundary. The 'prev' side is the earlier shift (e.g.,
|
|
849
|
+
M for the M→A boundary); 'next' is the later shift (A). Either side may
|
|
850
|
+
be None for self-planning surplus, but not both — that wouldn't be a pair.
|
|
851
|
+
|
|
852
|
+
``preplan_count`` carries the per-pair count of flights this pair's
|
|
853
|
+
prev-side plans for this pair's next-side. Default H6 values:
|
|
854
|
+
N→M=1, M→M1=1, M→A=2, M1→A1=2, A→N=3, A1→N=0, A→A1=0.
|
|
855
|
+
When N has 2 A-partners (split-1+2), the primary pair's count drops
|
|
856
|
+
to 2 and the secondary pair carries 1. Pairs with preplan_count=0
|
|
857
|
+
are handover-only.
|
|
858
|
+
"""
|
|
859
|
+
|
|
860
|
+
model_config = ConfigDict(frozen=True)
|
|
861
|
+
boundary: OpsBoundary
|
|
862
|
+
pair_role: PairRole
|
|
863
|
+
preplan_count: int = Field(default=0, ge=0, le=5)
|
|
864
|
+
prev_employee_id: str | None
|
|
865
|
+
prev_name: str | None
|
|
866
|
+
prev_shift: ShiftCode | None
|
|
867
|
+
next_employee_id: str | None
|
|
868
|
+
next_name: str | None
|
|
869
|
+
next_shift: ShiftCode | None
|
|
870
|
+
|
|
871
|
+
@model_validator(mode="after")
|
|
872
|
+
def _at_least_one_side_present(self) -> Self:
|
|
873
|
+
if self.prev_employee_id is None and self.next_employee_id is None:
|
|
874
|
+
raise ValueError("a pair must have at least one side populated")
|
|
875
|
+
return self
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
class P2FNomination(BaseModel):
|
|
879
|
+
"""Per (date, shift) P2F handler nomination from an override row."""
|
|
880
|
+
|
|
881
|
+
model_config = ConfigDict(frozen=True)
|
|
882
|
+
date: date_t
|
|
883
|
+
shift: ShiftCode
|
|
884
|
+
employee_id: str
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
class PerStaffOverride(BaseModel):
|
|
888
|
+
"""Per-staff Step-4 override row (separate from Step 3's
|
|
889
|
+
shift-swap overrides)."""
|
|
890
|
+
|
|
891
|
+
model_config = ConfigDict(frozen=True)
|
|
892
|
+
employee_id: str
|
|
893
|
+
date: date_t
|
|
894
|
+
max_flights: int | None = Field(default=None, ge=0, le=30)
|
|
895
|
+
std_cutoff: time_t | None = None
|
|
896
|
+
std_start: time_t | None = None
|
|
897
|
+
is_newbie: bool = False
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# 2026-05-24: four new override types for ad-hoc additions /
|
|
901
|
+
# removals of flights or staff on the planning day. Each row applies
|
|
902
|
+
# to D only, on top of whatever the SV portal + roster exports gave.
|
|
903
|
+
# The "stage-and-confirm" UX writes these into the workbook
|
|
904
|
+
# immediately on Save so the dashboard reflects the projected state
|
|
905
|
+
# (extra flight in the right ops-class card; extra body in the
|
|
906
|
+
# roster-by-shift block) BEFORE the assigner clicks Allocate.
|
|
907
|
+
|
|
908
|
+
class AddFlightOverride(BaseModel):
|
|
909
|
+
"""``type=add_flight`` — inject a new flight into the cleaned <class> list
|
|
910
|
+
so it enters the allocation pool.
|
|
911
|
+
|
|
912
|
+
Override columns used: flt, shift (used as STD HH:MM), dep, arr,
|
|
913
|
+
ops_class. The assigner provides every field the cleaning step
|
|
914
|
+
would have parsed from the SV portal — the engine treats this as
|
|
915
|
+
if it had appeared in IN_SVportal."""
|
|
916
|
+
|
|
917
|
+
model_config = ConfigDict(frozen=True)
|
|
918
|
+
date: date_t
|
|
919
|
+
flt: str
|
|
920
|
+
dep: str = Field(min_length=3, max_length=4)
|
|
921
|
+
arr: str = Field(min_length=3, max_length=4)
|
|
922
|
+
std: time_t
|
|
923
|
+
ops_class: OpsClass
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class RemoveFlightOverride(BaseModel):
|
|
927
|
+
"""``type=remove_flight`` — pull a flight out of the allocation pool.
|
|
928
|
+
|
|
929
|
+
Matches by (flt, std) tuple — same disambiguator used elsewhere for
|
|
930
|
+
multi-leg rotations. Removed flights are dropped from
|
|
931
|
+
the cleaned <class> list at apply time."""
|
|
932
|
+
|
|
933
|
+
model_config = ConfigDict(frozen=True)
|
|
934
|
+
date: date_t
|
|
935
|
+
flt: str
|
|
936
|
+
std: time_t
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
class AddStaffOverride(BaseModel):
|
|
940
|
+
"""``type=add_staff`` — inject a new (synthetic) staff into
|
|
941
|
+
the availability list so they participate in this day's allocation.
|
|
942
|
+
|
|
943
|
+
Override columns used: employee (name), shift (M/A/N/M1/A1 or
|
|
944
|
+
M/ZC / A/ZC / ...), role (STAFF / AM / ZC). Engine assigns a
|
|
945
|
+
synthetic employee_id of ``ADD_<n>`` so the schema's unique-ID
|
|
946
|
+
rule isn't violated."""
|
|
947
|
+
|
|
948
|
+
model_config = ConfigDict(frozen=True)
|
|
949
|
+
date: date_t
|
|
950
|
+
name: str = Field(min_length=1, max_length=80)
|
|
951
|
+
shift: ShiftCode
|
|
952
|
+
role: Role
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
class RemoveStaffOverride(BaseModel):
|
|
956
|
+
"""``type=remove_staff`` — pull a staff out of today's allocation.
|
|
957
|
+
|
|
958
|
+
Semantically identical to ``type=sick`` (which already exists);
|
|
959
|
+
kept as a separate type for UI clarity. Both flip the staff's
|
|
960
|
+
``assignable`` flag to False before Step 3 runs."""
|
|
961
|
+
|
|
962
|
+
model_config = ConfigDict(frozen=True)
|
|
963
|
+
date: date_t
|
|
964
|
+
name: str = Field(min_length=1, max_length=80)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
# ====================================================================
|
|
968
|
+
# Phase R — per-flight relaxation overrides (2026-05-15)
|
|
969
|
+
#
|
|
970
|
+
# These four override types let the assigner explicitly waive a hard
|
|
971
|
+
# constraint for ONE specific flight (or one specific (flight, staff)
|
|
972
|
+
# pair). Each row is read from the override list on D and the engine's
|
|
973
|
+
# eligibility / post-pass code checks the override set before applying
|
|
974
|
+
# the corresponding constraint.
|
|
975
|
+
#
|
|
976
|
+
# All four take effect only on the named date. Wiped by the standard
|
|
977
|
+
# reset_overrides flow.
|
|
978
|
+
# ====================================================================
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
class WaiveH10Pair(BaseModel):
|
|
982
|
+
"""Waive the H10 15-min spacing rule between ONE flight and ONE
|
|
983
|
+
staff's other flight at a given STD.
|
|
984
|
+
|
|
985
|
+
Override columns: type=waive_h10_pair, flight, std, employee,
|
|
986
|
+
other_std. ``flight`` is the bare FLT number; ``std`` disambiguates
|
|
987
|
+
multi-leg rotations.
|
|
988
|
+
|
|
989
|
+
Example. flt 339 (STD 06:10) is unallocated because KARRA PRANAY
|
|
990
|
+
REDDY already has flt 6305 at 06:15. A WaiveH10Pair row of:
|
|
991
|
+
date=2026-05-06, flight=339, std=06:10,
|
|
992
|
+
employee=KARRA PRANAY REDDY, other_std=06:15
|
|
993
|
+
tells the solver: when building the H10 spacing constraint, EXCLUDE
|
|
994
|
+
the pair (flt 339 @ 06:10, flt @ 06:15) from the no-overlap set
|
|
995
|
+
for this staff.
|
|
996
|
+
|
|
997
|
+
Granular by design — does NOT relax H10 for the staff's other
|
|
998
|
+
flights; only this exact pair gets the waiver.
|
|
999
|
+
"""
|
|
1000
|
+
|
|
1001
|
+
model_config = ConfigDict(frozen=True)
|
|
1002
|
+
date: date_t
|
|
1003
|
+
flight: str
|
|
1004
|
+
std: time_t
|
|
1005
|
+
employee_id: str
|
|
1006
|
+
other_std: time_t
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
class RaiseCapForFlight(BaseModel):
|
|
1010
|
+
"""Per-staff one-shot cap raise to absorb ONE specific flight.
|
|
1011
|
+
|
|
1012
|
+
Override columns: type=raise_cap, flight, std, employee.
|
|
1013
|
+
|
|
1014
|
+
Example. ARSHI KHAN is at 24/24 cap. A RaiseCapForFlight row of:
|
|
1015
|
+
date=2026-05-06, flight=1185, std=06:10, employee=ARSHI KHAN
|
|
1016
|
+
raises ARSHI's H16 cap by 1 (to 25) **for this run only**. The cap
|
|
1017
|
+
reverts after the engine completes — operationally we're not
|
|
1018
|
+
permanently changing the cap; we're just letting one extra flight
|
|
1019
|
+
land on this person.
|
|
1020
|
+
"""
|
|
1021
|
+
|
|
1022
|
+
model_config = ConfigDict(frozen=True)
|
|
1023
|
+
date: date_t
|
|
1024
|
+
flight: str
|
|
1025
|
+
std: time_t
|
|
1026
|
+
employee_id: str
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
class SkipP2FBuffer(BaseModel):
|
|
1030
|
+
"""Waive a P2F handler's buffer (STD-2hrs to STD+1hr) to allow ONE
|
|
1031
|
+
specific normal flight inside the window.
|
|
1032
|
+
|
|
1033
|
+
Override columns: type=skip_p2f_buffer, flight, std, employee.
|
|
1034
|
+
|
|
1035
|
+
Example. GAYATHRI is P2F-M handler. flt 6142 (08:35) falls in her
|
|
1036
|
+
P2F buffer (engine F6 hard-blocks). A SkipP2FBuffer row of:
|
|
1037
|
+
date=2026-05-06, flight=6142, std=08:35, employee=GAYATHRI
|
|
1038
|
+
tells eligibility F6 to skip the buffer check for this exact
|
|
1039
|
+
(flight, staff) pair.
|
|
1040
|
+
"""
|
|
1041
|
+
|
|
1042
|
+
model_config = ConfigDict(frozen=True)
|
|
1043
|
+
date: date_t
|
|
1044
|
+
flight: str
|
|
1045
|
+
std: time_t
|
|
1046
|
+
employee_id: str
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
class SkipINTLRemoval(BaseModel):
|
|
1050
|
+
"""Tell the INTL post-pass NOT to remove a preceding flight when
|
|
1051
|
+
this specific INTL DEP flight gets allocated.
|
|
1052
|
+
|
|
1053
|
+
Override columns: type=skip_intl_removal, flight, std.
|
|
1054
|
+
|
|
1055
|
+
Example. flt 1064 lands on MANISH KUMAR. By default the post-pass
|
|
1056
|
+
removes MANISH's chronologically previous normal flight and
|
|
1057
|
+
redistributes. A SkipINTLRemoval row of:
|
|
1058
|
+
date=2026-05-06, flight=1064, std=10:25
|
|
1059
|
+
keeps both flights on MANISH. Used when the assigner explicitly
|
|
1060
|
+
decides the buffer isn't needed for this INTL.
|
|
1061
|
+
"""
|
|
1062
|
+
|
|
1063
|
+
model_config = ConfigDict(frozen=True)
|
|
1064
|
+
date: date_t
|
|
1065
|
+
flight: str
|
|
1066
|
+
std: time_t
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
class AllocationRow(BaseModel):
|
|
1070
|
+
"""One allocation row. The display name and employee_id are
|
|
1071
|
+
both stored: names are for the human reader, employee_ids are for any
|
|
1072
|
+
downstream rejoin (e.g., the workload summary).
|
|
1073
|
+
|
|
1074
|
+
Per user direction 2026-05-11: ``staff_*`` may be empty for
|
|
1075
|
+
"pre-plan only" flights (05:05–05:30 D+1 STDs whose actual fly-er
|
|
1076
|
+
is on D+1's M shift — but D+1's roster isn't loaded yet). Those
|
|
1077
|
+
rows carry a ``planned_by_*`` (the N-shift planner) and no staff.
|
|
1078
|
+
"""
|
|
1079
|
+
|
|
1080
|
+
model_config = ConfigDict(frozen=True)
|
|
1081
|
+
date: date_t
|
|
1082
|
+
flt: str
|
|
1083
|
+
dep: str
|
|
1084
|
+
arr: str
|
|
1085
|
+
std: time_t
|
|
1086
|
+
pax: int = Field(ge=0, le=600)
|
|
1087
|
+
staff_employee_id: str = ""
|
|
1088
|
+
staff_name: str = ""
|
|
1089
|
+
planned_by_employee_id: str | None = None
|
|
1090
|
+
planned_by_name: str | None = None
|
|
1091
|
+
relieved_by_employee_id: str | None = None
|
|
1092
|
+
relieved_by_name: str | None = None
|
|
1093
|
+
warning: str | None = None
|
|
1094
|
+
sheet_target: AllocationSheet
|
|
1095
|
+
# Color hint carried through from CleanFlightRow → solver → output.
|
|
1096
|
+
# Workbook writer applies cell fills; web readback returns the
|
|
1097
|
+
# flag so the browser tints rows yellow. Phase 3 (2026-05-14) made
|
|
1098
|
+
# is_international DEP-only; the pink-highlight machinery
|
|
1099
|
+
# (routing_via_excluded_hub) was deleted.
|
|
1100
|
+
is_international: bool = False
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
class WorkloadSummaryRow(BaseModel):
|
|
1104
|
+
"""One row of OUT_Workload_Summary."""
|
|
1105
|
+
|
|
1106
|
+
model_config = ConfigDict(frozen=True)
|
|
1107
|
+
employee_id: str
|
|
1108
|
+
name: str
|
|
1109
|
+
shift: ShiftCode | None
|
|
1110
|
+
role: Role
|
|
1111
|
+
target_preferred: int
|
|
1112
|
+
target_acceptable_max: int
|
|
1113
|
+
hard_cap: int
|
|
1114
|
+
actual: int
|
|
1115
|
+
deviation_below_preferred: int = Field(ge=0)
|
|
1116
|
+
deviation_above_preferred: int = Field(ge=0)
|
|
1117
|
+
violations: tuple[str, ...] = ()
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
class AllocationResult(BaseModel):
|
|
1121
|
+
"""Top-level Step 4 output bundle returned by the orchestrator."""
|
|
1122
|
+
|
|
1123
|
+
model_config = ConfigDict(frozen=True)
|
|
1124
|
+
rows: tuple[AllocationRow, ...]
|
|
1125
|
+
pairs: tuple[Pair, ...]
|
|
1126
|
+
summary: tuple[WorkloadSummaryRow, ...]
|
|
1127
|
+
warnings: tuple[WarningRow, ...]
|
|
1128
|
+
wall_clock_seconds: float = Field(ge=0)
|
|
1129
|
+
solver_status: str
|
|
1130
|
+
"""OPTIMAL | FEASIBLE | INFEASIBLE | TIMEOUT — short-form status from
|
|
1131
|
+
the solver run."""
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
# ---------- Agent (Phase 1: write-action chassis, 2026-05-13) ----------
|
|
1135
|
+
#
|
|
1136
|
+
# These models are consumed only by ``src/agent/`` and the
|
|
1137
|
+
# two new audit sheets in ``OUT_Agent_Trace`` / ``OUT_Agent_Queue``.
|
|
1138
|
+
# Nothing in the existing pipeline (allocation, eligibility, post-passes,
|
|
1139
|
+
# readers, writers, pairings, web UI) imports from this block — it's
|
|
1140
|
+
# isolated by design so the chassis can land without engine impact.
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
class WriteAction(StrEnum):
|
|
1144
|
+
"""The kind of action a single audit-trace row describes.
|
|
1145
|
+
|
|
1146
|
+
READ — agent read state via a read-only tool (kept here
|
|
1147
|
+
so the same trace sheet works for Phase R too).
|
|
1148
|
+
WRITE_PREVIEW — ApprovalGate.preview() built; nothing committed.
|
|
1149
|
+
APPROVAL_RECORDED — human clicked Approve; token issued.
|
|
1150
|
+
APPROVAL_REJECTED — human clicked Reject; gate dead.
|
|
1151
|
+
WRITE_APPLY — agent applied the previously-approved change.
|
|
1152
|
+
ROLLBACK — WriteTransaction restored from backup after a
|
|
1153
|
+
mid-apply failure.
|
|
1154
|
+
"""
|
|
1155
|
+
|
|
1156
|
+
READ = "READ"
|
|
1157
|
+
WRITE_PREVIEW = "WRITE_PREVIEW"
|
|
1158
|
+
APPROVAL_RECORDED = "APPROVAL_RECORDED"
|
|
1159
|
+
APPROVAL_REJECTED = "APPROVAL_REJECTED"
|
|
1160
|
+
WRITE_APPLY = "WRITE_APPLY"
|
|
1161
|
+
ROLLBACK = "ROLLBACK"
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
class ApprovalStatus(StrEnum):
|
|
1165
|
+
"""Lifecycle state of a single ApprovalGate, persisted in SQLite.
|
|
1166
|
+
|
|
1167
|
+
PENDING — preview built, awaiting human decision.
|
|
1168
|
+
APPROVED — token issued, not yet applied or expired.
|
|
1169
|
+
APPLIED — token consumed by a successful apply().
|
|
1170
|
+
REJECTED — human said no.
|
|
1171
|
+
EXPIRED — TTL elapsed before apply() was called.
|
|
1172
|
+
"""
|
|
1173
|
+
|
|
1174
|
+
PENDING = "PENDING"
|
|
1175
|
+
APPROVED = "APPROVED"
|
|
1176
|
+
APPLIED = "APPLIED"
|
|
1177
|
+
REJECTED = "REJECTED"
|
|
1178
|
+
EXPIRED = "EXPIRED"
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
class AgentTraceRow(BaseModel):
|
|
1182
|
+
"""One row of the OUT_Agent_Trace sheet — the forensic log of every
|
|
1183
|
+
agent action.
|
|
1184
|
+
|
|
1185
|
+
The sheet is append-only by design: ``reset_outputs`` excludes it so
|
|
1186
|
+
audit history survives Plan / Allocate runs. Manual purge via a
|
|
1187
|
+
separate "clear agent history" tool when the assigner explicitly
|
|
1188
|
+
wants it gone.
|
|
1189
|
+
|
|
1190
|
+
Hash columns let an auditor confirm the pre/post state without
|
|
1191
|
+
storing entire workbook snapshots inline.
|
|
1192
|
+
"""
|
|
1193
|
+
|
|
1194
|
+
model_config = ConfigDict(frozen=True)
|
|
1195
|
+
timestamp: datetime_t
|
|
1196
|
+
txn_id: str
|
|
1197
|
+
"""ULID-style identifier; ties together preview + approval + apply +
|
|
1198
|
+
(optional) rollback rows for the same logical action."""
|
|
1199
|
+
action_type: WriteAction
|
|
1200
|
+
tool_name: str
|
|
1201
|
+
"""The agent tool that emitted this row (e.g. add_intl_airport,
|
|
1202
|
+
update_roster_cell, mark_unavailable)."""
|
|
1203
|
+
target: str
|
|
1204
|
+
"""Plain-English description of what was touched
|
|
1205
|
+
(e.g. "configs/config.yml#international_airports", "override row 7")."""
|
|
1206
|
+
pre_state_hash: str = ""
|
|
1207
|
+
"""SHA-256 hex digest of the affected file/sheet before the change.
|
|
1208
|
+
Empty string for READ rows."""
|
|
1209
|
+
post_state_hash: str = ""
|
|
1210
|
+
"""SHA-256 hex digest of the affected file/sheet after the change.
|
|
1211
|
+
Empty string for READ and PREVIEW rows (no change yet)."""
|
|
1212
|
+
approval_user: str = ""
|
|
1213
|
+
"""Empty for READ / PREVIEW / ROLLBACK; the human who clicked
|
|
1214
|
+
Approve/Reject for the approval-bound rows."""
|
|
1215
|
+
approval_timestamp: datetime_t | None = None
|
|
1216
|
+
note: str = ""
|
|
1217
|
+
"""Free-form: rejection reason, rollback cause, plan-summary blurb, etc."""
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
class AgentQueueRow(BaseModel):
|
|
1221
|
+
"""One row of the OUT_Agent_Queue sheet — items the agent flagged for
|
|
1222
|
+
human triage via the ``flag_for_human`` tool (CONTEXT.md §2.7).
|
|
1223
|
+
|
|
1224
|
+
Kept separate from OUT_Agent_Trace so the assigner has a focused
|
|
1225
|
+
work-list view independent of the firehose of read/write events.
|
|
1226
|
+
"""
|
|
1227
|
+
|
|
1228
|
+
model_config = ConfigDict(frozen=True)
|
|
1229
|
+
timestamp: datetime_t
|
|
1230
|
+
queue_id: str
|
|
1231
|
+
"""Stable identifier so the UI can mark rows resolved/dismissed
|
|
1232
|
+
without re-keying by row index."""
|
|
1233
|
+
question: str
|
|
1234
|
+
"""The user's original prompt that triggered the escalation."""
|
|
1235
|
+
why_stuck: str
|
|
1236
|
+
"""The agent's own statement of why it couldn't resolve confidently."""
|
|
1237
|
+
partial_evidence: str
|
|
1238
|
+
"""JSON-serialized snapshot of what the agent had figured out so far
|
|
1239
|
+
(citations, partial findings). Read-only context for the human."""
|
|
1240
|
+
confidence: Literal["HIGH", "MEDIUM", "LOW"] = "LOW"
|
|
1241
|
+
status: Literal["OPEN", "RESOLVED", "DISMISSED"] = "OPEN"
|
|
1242
|
+
resolved_by: str = ""
|
|
1243
|
+
resolved_at: datetime_t | None = None
|
|
1244
|
+
resolution_note: str = ""
|