codex-services 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ """
2
+ codex_services.booking.slot_master.dto
3
+ ======================================
4
+ Pydantic v2 DTO (Data Transfer Objects) for the slot-master booking engine.
5
+
6
+ All models are immutable (frozen=True via BaseDTO) — the engine does not mutate inputs.
7
+ No Django imports. Only Python stdlib + pydantic + codex_core.
8
+
9
+ Imports:
10
+ from codex_services.booking.slot_master import (
11
+ BookingEngineRequest, ServiceRequest,
12
+ MasterAvailability, EngineResult, BookingChainSolution,
13
+ )
14
+ """
15
+
16
+ from datetime import date, datetime
17
+ from typing import Any
18
+
19
+ from codex_core.core.base_dto import BaseDTO
20
+ from pydantic import Field
21
+
22
+ from codex_services.booking._shared.dto import (
23
+ BookingRequest,
24
+ BookingSolution,
25
+ ResourceAvailability,
26
+ )
27
+
28
+ from .modes import BookingMode
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Входные DTO (что подаётся в движок)
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class ServiceRequest(BaseDTO):
36
+ """
37
+ Request for a single service within a booking chain.
38
+
39
+ The engine treats this as an atomic requirement. It operates with abstract
40
+ resource IDs (possible_resource_ids) rather than specific business entities.
41
+
42
+ Fields:
43
+ service_id (str):
44
+ Unique identifier of the service. String for universality
45
+ (can be "5", "uuid-xxx", "task-1" — doesn't matter).
46
+
47
+ duration_minutes (int):
48
+ Duration of the service in minutes. Must be > 0.
49
+
50
+ min_gap_after_minutes (int):
51
+ Minimum gap (minutes) after this service before the next one
52
+ in the chain. Default is 0 (no gap).
53
+ Example: if there is a cooling down period of 30 mins → min_gap_after_minutes=30.
54
+
55
+ possible_resource_ids (list[str]):
56
+ List of resource IDs capable of performing this service.
57
+ The engine chooses an available resource from this list.
58
+ For RESOURCE_LOCKED mode, this should contain exactly one element.
59
+
60
+ parallel_group (str | None):
61
+ Tag for parallel execution group.
62
+ Services with the same parallel_group can be performed simultaneously
63
+ by different resources (if overlap_allowed=True is set in the request).
64
+ None = service is performed independently (standard sequential behavior).
65
+
66
+ Example:
67
+ ```python
68
+ ServiceRequest(
69
+ service_id="5",
70
+ duration_minutes=60,
71
+ possible_resource_ids=["1", "3", "7"],
72
+ )
73
+ ```
74
+ """
75
+
76
+ service_id: str
77
+ duration_minutes: int = Field(gt=0, description="Длительность в минутах")
78
+ min_gap_after_minutes: int = Field(default=0, ge=0, description="Пауза после услуги перед следующей")
79
+ possible_resource_ids: list[str] = Field(min_length=1, description="Хотя бы один ресурс должен быть указан")
80
+ parallel_group: str | None = Field(
81
+ default=None,
82
+ description="Метка группы параллельного выполнения (одинаковый тег = одновременно)",
83
+ )
84
+
85
+ @property
86
+ def total_block_minutes(self) -> int:
87
+ """Return total time that blocks the resource: duration + gap after."""
88
+ return self.duration_minutes + self.min_gap_after_minutes
89
+
90
+ def __repr__(self) -> str:
91
+ return f"<ServiceRequest id={self.service_id} dur={self.duration_minutes}>"
92
+
93
+
94
+ class BookingEngineRequest(BookingRequest):
95
+ """
96
+ Input request describing the entire desired booking chain.
97
+
98
+ Orchestrates multiple ServiceRequests into a single search task.
99
+ Inherits booking_date from BookingRequest.
100
+
101
+ Fields:
102
+ service_requests (list[ServiceRequest]):
103
+ List of services to book. Order is critical for SINGLE_DAY mode —
104
+ the engine will schedule them in the specified sequence.
105
+ Minimum 1 service required.
106
+
107
+ booking_date (date):
108
+ Target date. Inherited from BookingRequest.
109
+ Used for SINGLE_DAY and RESOURCE_LOCKED.
110
+ In MULTI_DAY mode, this represents the date of the first service.
111
+
112
+ mode (BookingMode):
113
+ Engine operating strategy. Default is SINGLE_DAY.
114
+
115
+ overlap_allowed (bool):
116
+ Allow parallel execution of services by different resources.
117
+ False (default) — each subsequent service starts only after the
118
+ previous one (plus its gap) ends.
119
+ True — resources can work independently; services may start
120
+ simultaneously if resources are available.
121
+
122
+ group_size (int):
123
+ DEPRECATED. Use duplication of ServiceRequest with parallel_group.
124
+
125
+ max_chain_duration_minutes (int | None):
126
+ Maximum total duration of the entire booking (from start of first
127
+ to end of last service). None = no limit.
128
+
129
+ days_gap (list[int] | None):
130
+ Day offsets for each service. Used strictly in MULTI_DAY mode.
131
+
132
+ Example:
133
+ ```python
134
+ BookingEngineRequest(
135
+ service_requests=[svc_1, svc_2],
136
+ booking_date=date(2024, 5, 10),
137
+ mode=BookingMode.SINGLE_DAY,
138
+ overlap_allowed=True
139
+ )
140
+ ```
141
+ """
142
+
143
+ service_requests: list[ServiceRequest] = Field(min_length=1, description="Минимум одна услуга")
144
+ mode: BookingMode = BookingMode.SINGLE_DAY
145
+ overlap_allowed: bool = Field(
146
+ default=False,
147
+ description="Разрешить параллельное выполнение услуг разными ресурсами",
148
+ )
149
+ group_size: int = Field(
150
+ default=1,
151
+ ge=1,
152
+ description="DEPRECATED. Используйте дублирование ServiceRequest с parallel_group.",
153
+ )
154
+ max_chain_duration_minutes: int | None = Field(
155
+ default=None,
156
+ ge=1,
157
+ description="Макс. длительность всей цепочки в минутах (None = без лимита)",
158
+ )
159
+ days_gap: list[int] | None = Field(
160
+ default=None,
161
+ description="Смещение в днях для каждой услуги (только MULTI_DAY)",
162
+ )
163
+
164
+ @property
165
+ def total_duration_minutes(self) -> int:
166
+ """Calculate total duration of all services without gap pauses."""
167
+ return sum(s.duration_minutes for s in self.service_requests)
168
+
169
+ @property
170
+ def total_block_minutes(self) -> int:
171
+ """
172
+ Calculate total blocking time including pauses between services.
173
+ Used for quick checks: does the chain fit into a given window.
174
+ """
175
+ return sum(s.total_block_minutes for s in self.service_requests)
176
+
177
+ def __repr__(self) -> str:
178
+ return f"<BookingEngineRequest date={self.booking_date} services={len(self.service_requests)}>"
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Данные доступности (подготавливаются адаптером)
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ class MasterAvailability(ResourceAvailability):
187
+ """
188
+ Available time windows of a resource for the slot-master booking type.
189
+
190
+ Inherits resource_id and free_windows from ResourceAvailability.
191
+ Adds slot-master-specific fields: buffer_between_minutes and work_start.
192
+
193
+ Fields:
194
+ resource_id (str): Resource identifier (inherited).
195
+ free_windows (list[tuple[datetime, datetime]]): List of (start, end) tuples (inherited).
196
+ buffer_between_minutes (int): Minimum buffer required between bookings.
197
+ work_start (datetime | None): Shift start anchor for slot alignment.
198
+
199
+ Example:
200
+ ```python
201
+ MasterAvailability(
202
+ resource_id="resource_1",
203
+ free_windows=[(datetime(2024,5,10,9,0), datetime(2024,5,10,12,0))],
204
+ buffer_between_minutes=10,
205
+ )
206
+ ```
207
+ """
208
+
209
+ buffer_between_minutes: int = Field(default=0, ge=0)
210
+ work_start: datetime | None = Field(
211
+ default=None,
212
+ description="Якорь для выравнивания сетки слотов (например, начало смены)",
213
+ )
214
+
215
+ def __repr__(self) -> str:
216
+ return f"<MasterAvailability resource={self.resource_id} windows={len(self.free_windows)}>"
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Выходные DTO (результат работы движка)
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ class SingleServiceSolution(BookingSolution):
225
+ """
226
+ Found slot for a single service in a booking chain.
227
+
228
+ Inherits resource_id, start_time, end_time from BookingSolution.
229
+ Adds service_id and gap_end_time.
230
+
231
+ Fields:
232
+ service_id (str): Reference to the original ServiceRequest.service_id.
233
+ resource_id (str): Identifier of the resource assigned to this service (inherited).
234
+ start_time (datetime): Scheduled start time of the service (inherited).
235
+ end_time (datetime): Scheduled completion time (excluding gap) (inherited).
236
+ gap_end_time (datetime): End of the blocking period (end_time + gap).
237
+
238
+ Example:
239
+ ```python
240
+ slot = SingleServiceSolution(
241
+ service_id="5",
242
+ resource_id="1",
243
+ start_time=datetime(2024, 5, 10, 10, 0),
244
+ end_time=datetime(2024, 5, 10, 11, 0),
245
+ gap_end_time=datetime(2024, 5, 10, 11, 15),
246
+ )
247
+ ```
248
+ """
249
+
250
+ service_id: str
251
+ gap_end_time: datetime # end_time + min_gap_after_minutes
252
+
253
+ def __repr__(self) -> str:
254
+ # GDPR Safe: Only IDs and Times. No notes, names, or PII.
255
+ return (
256
+ f"<SingleServiceSolution svc={self.service_id} "
257
+ f"res={self.resource_id} "
258
+ f"start={self.start_time.strftime('%H:%M')}>"
259
+ )
260
+
261
+
262
+ class BookingChainSolution(BaseDTO):
263
+ """
264
+ One complete solution for the entire request (set of slots for all services).
265
+
266
+ Found by the engine. Guarantees no conflicts between services and
267
+ respects all resource availability constraints.
268
+
269
+ Fields:
270
+ items (list[SingleServiceSolution]):
271
+ List of slots in the order of service execution.
272
+
273
+ score (float):
274
+ Quality score of the solution (higher is better).
275
+ Can be influenced by preferred resources, idle time, or resource reuse.
276
+
277
+ Example:
278
+ ```python
279
+ solution = BookingChainSolution(items=[slot1, slot2], score=10.0)
280
+ print(f"Booking span: {solution.span_minutes} minutes")
281
+ ```
282
+ """
283
+
284
+ items: list[SingleServiceSolution] = Field(min_length=1)
285
+ score: float = Field(default=0.0, description="Оценка качества решения")
286
+
287
+ @property
288
+ def starts_at(self) -> datetime:
289
+ """Return the start time of the first service in the chain."""
290
+ return min(s.start_time for s in self.items)
291
+
292
+ @property
293
+ def ends_at(self) -> datetime:
294
+ """Return the end time of the last service (excluding gap)."""
295
+ return max(s.end_time for s in self.items)
296
+
297
+ @property
298
+ def span_minutes(self) -> int:
299
+ """Return total time from the start of the first to the end of the last service."""
300
+ return int((self.ends_at - self.starts_at).total_seconds() / 60)
301
+
302
+ def to_display(self) -> dict[str, Any]:
303
+ """
304
+ Convert the solution into a dictionary for UI/serialization.
305
+
306
+ Returns:
307
+ Dict: {service_id: {resource_id, start, end}, ...}
308
+ """
309
+ return {
310
+ item.service_id: {
311
+ "resource_id": item.resource_id,
312
+ "start": item.start_time.strftime("%H:%M"),
313
+ "end": item.end_time.strftime("%H:%M"),
314
+ }
315
+ for item in self.items
316
+ }
317
+
318
+ def __repr__(self) -> str:
319
+ # GDPR Safe: Only structural info.
320
+ return f"<BookingChainSolution score={self.score:.2f} items={self.items}>"
321
+
322
+
323
+ class EngineResult(BaseDTO):
324
+ """
325
+ Engine work result containing all discovered solutions.
326
+
327
+ Fields:
328
+ mode (BookingMode): The search strategy used.
329
+ solutions (list[BookingChainSolution]): Found valid schedule options.
330
+
331
+ Example:
332
+ ```python
333
+ result = ChainFinder().find(request, availability)
334
+ if result.has_solutions:
335
+ print(f"Best start: {result.best.starts_at}")
336
+ ```
337
+ """
338
+
339
+ mode: BookingMode
340
+ solutions: list[BookingChainSolution] = Field(default_factory=list)
341
+
342
+ @property
343
+ def has_solutions(self) -> bool:
344
+ """Return True if at least one solution was found."""
345
+ return len(self.solutions) > 0
346
+
347
+ @property
348
+ def best(self) -> BookingChainSolution | None:
349
+ """
350
+ Return the primary solution.
351
+ - Without scorer: earliest by start time.
352
+ - After scoring: the option with the highest score.
353
+ """
354
+ return self.solutions[0] if self.solutions else None
355
+
356
+ @property
357
+ def best_scored(self) -> BookingChainSolution | None:
358
+ """
359
+ Return the option with the maximum score among all solutions.
360
+ Differs from 'best' if the solution list hasn't been sorted yet.
361
+ """
362
+ if not self.solutions:
363
+ return None
364
+ return max(self.solutions, key=lambda s: s.score)
365
+
366
+ def get_unique_start_times(self) -> list[str]:
367
+ """
368
+ Return unique start times of the first service for UI grid display.
369
+
370
+ Returns:
371
+ List[str]: ["09:00", "09:30", ...]
372
+ """
373
+ times = {s.starts_at.strftime("%H:%M") for s in self.solutions}
374
+ return sorted(times)
375
+
376
+ def __repr__(self) -> str:
377
+ return f"<EngineResult mode={self.mode} solutions_count={len(self.solutions)}>"
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # Waitlist DTO (результат find_nearest / waitlist-уведомлений)
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ class WaitlistEntry(BaseDTO):
386
+ """
387
+ Notification data for a nearest available slot for waitlisted clients.
388
+
389
+ Used when a desired slot was unavailable, but an alternative was found
390
+ (e.g., via find_nearest() or a background worker).
391
+
392
+ Fields:
393
+ available_date (date): Date of the found alternative slot.
394
+ available_time (str): Start time of the first service ("HH:MM").
395
+ solution (BookingChainSolution): Complete schedule details.
396
+ days_from_request (int): Delta from original request date for ranking.
397
+
398
+ Example:
399
+ # Worker detects a cancellation:
400
+ result = finder.find_nearest(request, search_from=original_date)
401
+ if result.has_solutions:
402
+ entry = WaitlistEntry.from_engine_result(result, original_date)
403
+ notify_client(entry)
404
+ """
405
+
406
+ available_date: date
407
+ available_time: str # "HH:MM"
408
+ solution: BookingChainSolution
409
+ days_from_request: int = 0
410
+
411
+ @classmethod
412
+ def from_engine_result(
413
+ cls,
414
+ result: "EngineResult",
415
+ original_date: date,
416
+ ) -> "WaitlistEntry | None":
417
+ """
418
+ Factory method to create a WaitlistEntry from an EngineResult.
419
+
420
+ Args:
421
+ result: The engine's output.
422
+ original_date: Original requested date for delta calculation.
423
+
424
+ Returns:
425
+ WaitlistEntry or None if no solutions exist.
426
+ """
427
+ if not result.has_solutions:
428
+ return None
429
+
430
+ solution = result.best
431
+ if solution is None: # pragma: no cover
432
+ return None # pragma: no cover
433
+
434
+ available_date = solution.starts_at.date()
435
+ available_time = solution.starts_at.strftime("%H:%M")
436
+ days_delta = (available_date - original_date).days
437
+
438
+ return cls(
439
+ available_date=available_date,
440
+ available_time=available_time,
441
+ solution=solution,
442
+ days_from_request=max(0, days_delta),
443
+ )
444
+
445
+ def __repr__(self) -> str:
446
+ return f"<WaitlistEntry date={self.available_date} time={self.available_time}>"
@@ -0,0 +1,44 @@
1
+ """
2
+ codex_services.booking.slot_master.modes
3
+ ========================================
4
+ Operating modes for the slot-master booking engine.
5
+
6
+ Imports:
7
+ from codex_services.booking.slot_master import BookingMode
8
+ """
9
+
10
+ import sys
11
+ from enum import Enum
12
+
13
+ if sys.version_info >= (3, 11): # pragma: no cover
14
+ from enum import StrEnum # pragma: no cover
15
+ else:
16
+
17
+ class StrEnum(str, Enum):
18
+ pass
19
+
20
+
21
+ class BookingMode(StrEnum):
22
+ """
23
+ Operating mode for ChainFinder.
24
+
25
+ SINGLE_DAY:
26
+ All services from the request must fit within a single day.
27
+ The common mode — multiple services in one visit.
28
+
29
+ MULTI_DAY:
30
+ Each service can be scheduled for a different day (Stub).
31
+
32
+ RESOURCE_LOCKED:
33
+ Booking for a specific resource (e.g., from their personal page).
34
+ The engine relies entirely on the provided resource constraints.
35
+
36
+ Example:
37
+ ```python
38
+ mode = BookingMode.SINGLE_DAY
39
+ ```
40
+ """
41
+
42
+ SINGLE_DAY = "single_day"
43
+ MULTI_DAY = "multi_day"
44
+ RESOURCE_LOCKED = "resource_locked"
@@ -0,0 +1,168 @@
1
+ """
2
+ codex_services.booking.slot_master.scorer
3
+ =========================================
4
+ Evaluation and ranking of booking engine solutions.
5
+
6
+ Pure Python — no Django dependencies.
7
+ Applied AFTER ChainFinder.find() to sort solutions by quality.
8
+
9
+ Does not affect the search algorithm — only the output order.
10
+ BookingChainSolution.score is set here.
11
+
12
+ Quick start:
13
+ from codex_services.booking.slot_master import ChainFinder
14
+ from codex_services.booking.slot_master.scorer import BookingScorer, ScoringWeights
15
+
16
+ result = ChainFinder().find(request, availability)
17
+
18
+ scorer = BookingScorer(
19
+ weights=ScoringWeights(preferred_resource_bonus=15.0),
20
+ preferred_resource_ids=["3", "7"],
21
+ )
22
+ ranked = scorer.score(result)
23
+ best = ranked.best # solution with the highest score
24
+ """
25
+
26
+ from dataclasses import dataclass
27
+
28
+ from .dto import BookingChainSolution, EngineResult
29
+
30
+
31
+ @dataclass
32
+ class ScoringWeights:
33
+ """
34
+ Weights for solution evaluation criteria. Configurable per project.
35
+
36
+ All weights are additive: total score = sum of applicable bonuses.
37
+ Higher score = more preferred solution.
38
+
39
+ Fields:
40
+ preferred_resource_bonus (float):
41
+ Bonus for each service with a preferred resource.
42
+ Passed via BookingScorer(preferred_resource_ids=[...]).
43
+ Example: client always visits Anya -> preferred_resource_ids=["3"].
44
+
45
+ same_resource_bonus (float):
46
+ Bonus if one resource performs multiple services.
47
+ Reduces the number of times the client must switch resources.
48
+
49
+ min_idle_bonus_per_hour (float):
50
+ Bonus for each hour of minimized idle time between services.
51
+ Encourages compact chains.
52
+
53
+ early_slot_penalty_per_hour (float):
54
+ Penalty for each hour from the start of the workday to the first service.
55
+ Encourages earlier slots (less index = better).
56
+ Negative value is not needed here — subtracted automatically.
57
+
58
+ Example (encourage early times AND preferred resource):
59
+ ScoringWeights(
60
+ preferred_resource_bonus=20.0,
61
+ early_slot_penalty_per_hour=1.0,
62
+ )
63
+ """
64
+
65
+ preferred_resource_bonus: float = 10.0
66
+ same_resource_bonus: float = 5.0
67
+ min_idle_bonus_per_hour: float = 2.0
68
+ early_slot_penalty_per_hour: float = 0.0
69
+
70
+
71
+ class BookingScorer:
72
+ """
73
+ Evaluates engine solutions and returns an EngineResult with populated scores.
74
+
75
+ Usage:
76
+ scorer = BookingScorer(
77
+ weights=ScoringWeights(preferred_resource_bonus=15.0),
78
+ preferred_resource_ids=["3", "7"],
79
+ )
80
+ ranked = scorer.score(result)
81
+
82
+ # Best solution by score (not just the earliest):
83
+ print(ranked.best.score)
84
+ print(ranked.best.to_display())
85
+
86
+ # All solutions sorted by score (highest -> first):
87
+ for solution in ranked.solutions:
88
+ print(solution.score, solution.starts_at)
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ weights: ScoringWeights | None = None,
94
+ preferred_resource_ids: list[str] | None = None,
95
+ ) -> None:
96
+ """
97
+ Args:
98
+ weights: Criteria weights. None = ScoringWeights() with defaults.
99
+ preferred_resource_ids: List of IDs for preferred resources.
100
+ Must be strings (str(resource.pk)).
101
+ On match -> preferred_resource_bonus applied.
102
+ """
103
+ self.weights = weights or ScoringWeights()
104
+ self.preferred_ids: set[str] = set(preferred_resource_ids or [])
105
+
106
+ def score(self, result: EngineResult) -> EngineResult:
107
+ """
108
+ Populates a score for each solution and returns a re-sorted EngineResult.
109
+
110
+ Sorting: descending by score (best = first = result.best).
111
+ Solutions with the same score are sorted by starts_at (earlier = better).
112
+
113
+ Args:
114
+ result: EngineResult from ChainFinder.find().
115
+
116
+ Returns:
117
+ New EngineResult (using frozen=True -> model_copy) with populated scores
118
+ and solutions sorted by score DESC.
119
+ If result is empty, it is returned unchanged.
120
+ """
121
+ if not result.solutions:
122
+ return result
123
+
124
+ scored = [self._score_solution(s) for s in result.solutions]
125
+ # Sorting: score DESC, then starts_at ASC (earlier is better on equal score)
126
+ scored.sort(key=lambda s: (-s.score, s.starts_at))
127
+
128
+ return result.model_copy(update={"solutions": scored})
129
+
130
+ def _score_solution(self, solution: BookingChainSolution) -> BookingChainSolution:
131
+ """Calculates score for a single solution."""
132
+ score = 0.0
133
+ w = self.weights
134
+
135
+ # --- Bonus for preferred resource ---
136
+ if self.preferred_ids:
137
+ for item in solution.items:
138
+ if item.resource_id in self.preferred_ids:
139
+ score += w.preferred_resource_bonus
140
+
141
+ # --- Bonus for one resource carrying out multiple services ---
142
+ if w.same_resource_bonus > 0 and len(solution.items) > 1:
143
+ resource_counts: dict[str, int] = {}
144
+ for item in solution.items:
145
+ resource_counts[item.resource_id] = resource_counts.get(item.resource_id, 0) + 1
146
+ # Bonus for each pair of services with the same resource
147
+ for count in resource_counts.values():
148
+ if count > 1:
149
+ score += w.same_resource_bonus * (count - 1)
150
+
151
+ # --- Bonus for chain compactness (minimal idle time) ---
152
+ if w.min_idle_bonus_per_hour > 0 and len(solution.items) > 1:
153
+ # Idle time = span - total service duration
154
+ total_service_minutes = sum(item.duration_minutes for item in solution.items)
155
+ idle_minutes = solution.span_minutes - total_service_minutes
156
+ idle_hours = idle_minutes / 60.0
157
+ # Less idle = higher bonus (max for 0 idle)
158
+ max_idle_hours = solution.span_minutes / 60.0
159
+ compactness = 1.0 - (idle_hours / max_idle_hours) if max_idle_hours > 0 else 1.0
160
+ score += w.min_idle_bonus_per_hour * compactness * max_idle_hours
161
+
162
+ # --- Penalty for late start (encourages early slots) ---
163
+ if w.early_slot_penalty_per_hour > 0:
164
+ # Calculate hours from the start of the day (00:00) to starts_at
165
+ starts_hour = solution.starts_at.hour + solution.starts_at.minute / 60.0
166
+ score -= w.early_slot_penalty_per_hour * starts_hour
167
+
168
+ return solution.model_copy(update={"score": round(score, 4)})
@@ -0,0 +1,5 @@
1
+ """codex_services.calendar: UI calendar engine."""
2
+
3
+ from .engine import CalendarEngine
4
+
5
+ __all__ = ["CalendarEngine"]