keble-task 2.22.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,1295 @@
1
+ from collections.abc import Sequence
2
+ from datetime import datetime, timedelta, timezone
3
+ from decimal import Decimal
4
+ from enum import Enum
5
+ from typing import Annotated, Any, Optional
6
+ from urllib.parse import urlparse
7
+
8
+ import keble_exceptions
9
+ from bson import ObjectId as BsonObjectId
10
+ from keble_helpers import (
11
+ Currency,
12
+ ExchangeRateInUsd,
13
+ Language,
14
+ Money,
15
+ MongoObjectBase,
16
+ ObjectId,
17
+ PydanticModelConfig,
18
+ SchemaBase,
19
+ SharingScope,
20
+ Status,
21
+ Timestamp,
22
+ UsageAccountingSource,
23
+ ensure_aware_utc,
24
+ utc_now,
25
+ )
26
+ from pydantic import (
27
+ AliasChoices,
28
+ BaseModel,
29
+ BeforeValidator,
30
+ Field,
31
+ JsonValue,
32
+ TypeAdapter,
33
+ field_validator,
34
+ model_validator,
35
+ )
36
+ from pydantic_ai.usage import RunUsage
37
+ from pydantic_core import to_jsonable_python
38
+
39
+ from ..exceptions import TaskExceptionType
40
+
41
+ def _metadata_json_fallback(value: object) -> str:
42
+ """Serialize the few non-JSON-native scalars task metadata may carry.
43
+
44
+ Step by step:
45
+ 1. accept BSON ObjectIds because callers often reference Mongo ids internally;
46
+ 2. let `to_jsonable_python` handle native JSON, UUIDs, datetimes, and models;
47
+ 3. reject genuinely unknown objects instead of silently stringifying runtime state.
48
+ """
49
+
50
+ if isinstance(value, BsonObjectId):
51
+ return str(value)
52
+ raise ValueError(f"Unsupported task metadata type: {type(value).__name__}")
53
+
54
+
55
+ def _normalize_task_metadata(value: object) -> object:
56
+ """Normalize Mongo-native scalars in task metadata to JSON-safe values on READ.
57
+
58
+ Why: task metadata persisted before the JSON-safe write contract stored some
59
+ fields (e.g. `user_id`) as native `UUID`/BSON Binary. The strict
60
+ `dict[str, JsonValue]` shape rejects those on read. Running the same
61
+ JSON-normalization the write path uses keeps historic rows readable and makes
62
+ the model expose a consistent JSON-safe shape regardless of how the row was
63
+ stored. A no-op for already-JSON metadata; non-dicts pass through so
64
+ `Optional`/`None` and real type errors still surface from normal validation.
65
+ """
66
+
67
+ if not isinstance(value, dict):
68
+ return value
69
+ return to_jsonable_python(value, fallback=_metadata_json_fallback)
70
+
71
+
72
+ # Canonical JSON-native shape for free-form task metadata (title/subtitle/etc. context).
73
+ # The BeforeValidator normalizes Mongo-native scalars on read so task state,
74
+ # relations, and costs share one strongly-typed, JSON-safe metadata contract that
75
+ # stays readable across historic rows instead of a bare `dict`.
76
+ TaskRelationMetadata = Annotated[
77
+ dict[str, JsonValue], BeforeValidator(_normalize_task_metadata)
78
+ ]
79
+ _TASK_RELATION_METADATA_ADAPTER = TypeAdapter(TaskRelationMetadata)
80
+ TaskCostMetadata = Annotated[
81
+ dict[str, JsonValue], BeforeValidator(_normalize_task_metadata)
82
+ ]
83
+ _TASK_COST_METADATA_ADAPTER = TypeAdapter(TaskCostMetadata)
84
+ TaskMetadata = Annotated[
85
+ dict[str, JsonValue], BeforeValidator(_normalize_task_metadata)
86
+ ]
87
+ _MILLION = Decimal("1000000")
88
+
89
+
90
+ def build_task_relation_metadata(value: object | None) -> TaskRelationMetadata:
91
+ """Convert relation metadata into the public JSON-safe metadata contract.
92
+
93
+ Step by step:
94
+ 1. treat missing metadata as an empty object;
95
+ 2. convert supported Python values into JSON-compatible primitives;
96
+ 3. validate that the final value is a JSON object before persistence/events.
97
+ """
98
+
99
+ if value is None:
100
+ return {}
101
+ json_value = to_jsonable_python(
102
+ value,
103
+ fallback=_metadata_json_fallback,
104
+ )
105
+ return _TASK_RELATION_METADATA_ADAPTER.validate_python(json_value)
106
+
107
+
108
+ def build_task_cost_metadata(value: object | None) -> TaskCostMetadata:
109
+ """Convert task-cost metadata into the public JSON-safe metadata contract.
110
+
111
+ Step by step:
112
+ 1. normalize missing metadata to an empty object;
113
+ 2. convert supported Python values into JSON-compatible values;
114
+ 3. validate that the stored metadata remains a JSON object.
115
+ """
116
+
117
+ if value is None:
118
+ return {}
119
+ json_value = to_jsonable_python(
120
+ value,
121
+ fallback=_metadata_json_fallback,
122
+ )
123
+ return _TASK_COST_METADATA_ADAPTER.validate_python(json_value)
124
+
125
+
126
+ def _zero_usd_money() -> Money:
127
+ """Build a fresh zero-USD money value for default cost fields."""
128
+
129
+ return Money(float_money=0, currency=Currency.USD)
130
+
131
+
132
+ def _money_true_amount(*, money: Money) -> Decimal:
133
+ """Return the true decimal amount represented by `Money`.
134
+
135
+ Step by step:
136
+ 1. read the existing `StdMoneyAmount` integer minor-unit value;
137
+ 2. divide by 100 only once because `Money` stores cents;
138
+ 3. return Decimal so token costs below one cent are preserved.
139
+ """
140
+
141
+ return Decimal(money.amount.value) / Decimal("100")
142
+
143
+
144
+ def _convert_money_true_amount(
145
+ *,
146
+ money: Money,
147
+ exchange_rates: list[ExchangeRateInUsd],
148
+ currency: Currency,
149
+ ) -> Decimal:
150
+ """Convert a `Money` value and expose its true decimal amount.
151
+
152
+ Step by step:
153
+ 1. delegate currency conversion to `Money.exchange_to(...)`;
154
+ 2. convert the returned `Money` into a Decimal amount;
155
+ 3. keep sub-cent math available to aggregate responses.
156
+ """
157
+
158
+ converted = money.exchange_to(exchange_rates=exchange_rates, currency=currency)
159
+ return _money_true_amount(money=converted)
160
+
161
+
162
+ # [MOVED + RENAMED] from keble-product-report, ReportStage
163
+ # and renamed to ReportStage -> ReportStage
164
+ class TaskStage(str, Enum):
165
+ PENDING = "PENDING"
166
+ PROCESSING = "PROCESSING"
167
+ SUCCESS = "SUCCESS"
168
+ FAILURE = "FAILURE"
169
+
170
+ @property
171
+ def allow_to_start(self):
172
+ return self in [
173
+ # start from 0
174
+ TaskStage.PENDING,
175
+ # restart
176
+ TaskStage.PROCESSING,
177
+ ]
178
+
179
+
180
+ def is_http_url(v: Any) -> bool:
181
+ try:
182
+ parsed = urlparse(str(v))
183
+ except (TypeError, ValueError):
184
+ return False
185
+ if parsed.scheme not in {"http", "https"}:
186
+ return False
187
+ if not parsed.netloc:
188
+ return False
189
+ return True
190
+
191
+
192
+ # [MOVED + RENAMED] from keble-product-report, ReportBase
193
+ # and renamed to ReportBase -> TaskBase
194
+ class TaskBase(SchemaBase):
195
+ error: Optional[str] = None
196
+ exception_type: Optional[TaskExceptionType] = None
197
+
198
+ stage: TaskStage
199
+
200
+ # [RENAMED] report_type -> task_type
201
+ task_type: str # Type of Task, similar to keble-product-report ReportType
202
+
203
+ progress_key: Optional[str]
204
+
205
+ title: Optional[str]
206
+ subtitle: Optional[str]
207
+ image: Optional[str]
208
+ metadata: Optional[TaskMetadata]
209
+
210
+ # [ADDED] Canonical output/report language owned by the task framework. A task
211
+ # is the single source of truth for the language its handler should produce
212
+ # output in (report copy, localized labels, child tasks), so consumers read
213
+ # `task.language` instead of digging it out of domain-specific metadata blobs.
214
+ # Optional so legacy tasks created before this field still validate and load.
215
+ language: Optional[Language] = None
216
+
217
+ # [RENAMED] scope -> sharing_scope
218
+ sharing_scope: SharingScope = SharingScope.PRIVATE
219
+ # Public short id for sharing (only set when task is public)
220
+ public_id: Optional[str] = None
221
+ status: Status = Status.ACTIVE
222
+
223
+ owner: str
224
+
225
+ # processing started at what timestamp
226
+ started_ts: Optional[Timestamp] = None
227
+ success_ts: Optional[Timestamp] = None
228
+ failure_ts: Optional[Timestamp] = None
229
+
230
+ # [RENAMED] expected_feature_token -> expected_token
231
+ expected_token: int # expect to consume
232
+
233
+ # [RENAMED] consumed_feature_token -> consumed_token
234
+ consumed_token: int = 0 # actual consume
235
+
236
+ # [ADDED], retry mechanism
237
+ attempts: int = 0 # by default, attempts 0 times. Each times, it will increment further until reached maximum retried and should be marked as failure
238
+ timeout_mins: int = 120
239
+
240
+ # [ADDED] at 1.0.2, allow task to create subtasks
241
+ root_task: Optional[ObjectId] = (
242
+ None # which ROOT task, in otherword, the very first task in the chain, it should belongs to itself
243
+ )
244
+ parent_task: Optional[ObjectId] = (
245
+ None # which PARENT task, immediate parent task; In other word, which task created this subtask
246
+ )
247
+
248
+ @property
249
+ def unfinshed_timeout(self) -> bool:
250
+ before = utc_now() - timedelta(minutes=self.timeout_mins)
251
+ if self.created is None:
252
+ return False
253
+ c = ensure_aware_utc(self.created)
254
+ return before > c and self.stage in [TaskStage.PENDING, TaskStage.PROCESSING]
255
+
256
+ @property
257
+ def allow_to_retry(self) -> bool:
258
+ return self.attempts < 3 and self.stage.allow_to_start
259
+
260
+ @field_validator("image")
261
+ @classmethod
262
+ def validate_image_url(cls, v: Any) -> Optional[str]:
263
+ """
264
+ Validate image URL. If it's not a valid URL, keep it as a string.
265
+ """
266
+
267
+ if v is None:
268
+ return None
269
+
270
+ if not is_http_url(v):
271
+ raise keble_exceptions.ServerSideInvalidParams(
272
+ admin_note={"image": v},
273
+ alert_admin=True,
274
+ but_got="Image URL is not valid",
275
+ expected="Image URL is valid",
276
+ invalid_params="image",
277
+ )
278
+
279
+ return str(v)
280
+
281
+
282
+ class TaskUpdate(BaseModel):
283
+ """Owner-editable task patch.
284
+
285
+ Only fields the owner may change post-creation belong here. Fields left
286
+ unset are not written (``aowner_update`` dumps with ``exclude_unset``),
287
+ so partial patches never clobber other columns.
288
+ """
289
+
290
+ sharing_scope: Optional[SharingScope] = None
291
+ title: Optional[str] = None
292
+ # [ADDED] Owner-set thumbnail. An owner may re-image a task post-creation
293
+ # (e.g. attach a product photo). Optional + default None so omitting it leaves
294
+ # the persisted image untouched; passing None explicitly clears it.
295
+ image: Optional[str] = None
296
+
297
+ @field_validator("image")
298
+ @classmethod
299
+ def _validate_image(cls, v: Any) -> Optional[str]:
300
+ """Reuse the SAME rule as creation (:meth:`TaskBase.validate_image_url`).
301
+
302
+ Keeping one validator is the single source of truth for what a stored task
303
+ thumbnail may be, so an owner edit can never introduce an image that task
304
+ creation would have rejected.
305
+ """
306
+
307
+ return TaskBase.validate_image_url(v)
308
+
309
+
310
+ class TaskMongoObject(MongoObjectBase, TaskBase):
311
+ @property
312
+ def is_root_task(self) -> bool:
313
+ return self.parent_task is None
314
+
315
+
316
+ class TaskMongoObjectExtended(TaskMongoObject):
317
+ childs: list["TaskMongoObjectExtended"] = Field(default_factory=list)
318
+
319
+
320
+ TaskMongoObjectExtended.model_rebuild()
321
+
322
+
323
+ class TaskPublicRef(BaseModel):
324
+ """Lean public-task reference: id + last-updated only.
325
+
326
+ Intention: a projection-shaped read for sitemap-scale listings of PUBLIC tasks
327
+ (`apublic_list_indexable`) where loading the full `TaskMongoObject` (and its
328
+ childs / redis-backed extras) is wasteful. Carries ONLY what a sitemap `<url>`
329
+ needs: the task id (route param) and `updated` (lastmod).
330
+ """
331
+
332
+ model_config = PydanticModelConfig.default()
333
+
334
+ id: ObjectId = Field(
335
+ ...,
336
+ validation_alias=AliasChoices("_id", "id"),
337
+ pattern=r"^[0-9a-fA-F]{24}$",
338
+ )
339
+ updated: Optional[datetime] = None
340
+
341
+
342
+ class TaskRoomResolution(SchemaBase):
343
+ """Resolve one requested task into its canonical root-room identity.
344
+
345
+ Step by step:
346
+ 1. `root_task_id` is the durable workspace/session identifier;
347
+ 2. `requested_task_id` is the task the caller asked to focus on.
348
+ """
349
+
350
+ root_task_id: ObjectId
351
+ requested_task_id: ObjectId
352
+
353
+
354
+ class TaskRelationType(str, Enum):
355
+ """Supported non-tree lineage edge types inside one root-task workspace."""
356
+
357
+ MAPPED = "MAPPED"
358
+ REDUCED = "REDUCED"
359
+
360
+
361
+ class TaskRelationBase(SchemaBase):
362
+ """Extra lineage edge that does not change canonical task parentage.
363
+
364
+ Step by step:
365
+ 1. `root_task` scopes the relation to one task workspace;
366
+ 2. `from_task_id` records one source task that influenced another task;
367
+ 3. `to_task_id` records the derived task;
368
+ 4. `relation_type` describes why the edge exists.
369
+ """
370
+
371
+ root_task: ObjectId
372
+ from_task_id: ObjectId
373
+ to_task_id: ObjectId
374
+ relation_type: TaskRelationType
375
+ metadata: TaskRelationMetadata = Field(default_factory=dict)
376
+
377
+ @field_validator("metadata", mode="before")
378
+ @classmethod
379
+ def _normalize_metadata(cls, value: object) -> TaskRelationMetadata:
380
+ """Normalize persisted relation metadata before storing or emitting it."""
381
+
382
+ return build_task_relation_metadata(value)
383
+
384
+
385
+ class TaskRelationCreate(TaskRelationBase):
386
+ """Create payload for one extra task-lineage relation row."""
387
+
388
+
389
+ class TaskRelationUpdate(BaseModel):
390
+ """Update payload for mutable relation fields only."""
391
+
392
+ metadata: Optional[TaskRelationMetadata] = None
393
+
394
+ @field_validator("metadata", mode="before")
395
+ @classmethod
396
+ def _normalize_metadata(
397
+ cls, value: object | None
398
+ ) -> TaskRelationMetadata | None:
399
+ """Normalize relation metadata updates while preserving omitted updates."""
400
+
401
+ if value is None:
402
+ return None
403
+ return build_task_relation_metadata(value)
404
+
405
+
406
+ class TaskRelationMongoObject(MongoObjectBase, TaskRelationBase):
407
+ """Mongo representation of one extra task-lineage relation row."""
408
+
409
+
410
+ class TaskCostTimeBucket(str, Enum):
411
+ """Supported time buckets for task-cost aggregation."""
412
+
413
+ TOTAL = "TOTAL"
414
+ HOUR = "HOUR"
415
+ DAY = "DAY"
416
+ WEEK = "WEEK"
417
+ MONTH = "MONTH"
418
+
419
+
420
+ class TaskCostAggregateGroupBy(str, Enum):
421
+ """Task-cost aggregate dimensions supported by the stored row contract."""
422
+
423
+ TOTAL = "TOTAL"
424
+ TAG = "TAG"
425
+ SOURCE = "SOURCE"
426
+ TASK_TYPE = "TASK_TYPE"
427
+
428
+
429
+ class TaskCostTokenRates(BaseModel):
430
+ """Per-million token price table used to price one Pydantic AI run.
431
+
432
+ Step by step:
433
+ 1. store each `RunUsage` token field as a typed `Money` rate per million;
434
+ 2. default missing rates to zero USD so callers can price only charged fields;
435
+ 3. compute row costs with Decimal math so sub-cent token usage is not lost.
436
+ """
437
+
438
+ model_config = PydanticModelConfig.default()
439
+
440
+ input_tokens: Money = Field(default_factory=_zero_usd_money)
441
+ cache_write_tokens: Money = Field(default_factory=_zero_usd_money)
442
+ cache_read_tokens: Money = Field(default_factory=_zero_usd_money)
443
+ output_tokens: Money = Field(default_factory=_zero_usd_money)
444
+ input_audio_tokens: Money = Field(default_factory=_zero_usd_money)
445
+ cache_audio_read_tokens: Money = Field(default_factory=_zero_usd_money)
446
+ output_audio_tokens: Money = Field(default_factory=_zero_usd_money)
447
+
448
+ def calculate_usage_cost(
449
+ self,
450
+ *,
451
+ run_usage: RunUsage,
452
+ exchange_rates: list[ExchangeRateInUsd],
453
+ currency: Currency,
454
+ ) -> Decimal:
455
+ """Calculate the token-only cost for one `RunUsage` payload.
456
+
457
+ Step by step:
458
+ 1. convert every configured per-million rate into the requested currency;
459
+ 2. multiply each converted rate by its matching token count over one million;
460
+ 3. return the Decimal sum without rounding to cents.
461
+ """
462
+
463
+ return (
464
+ self._calculate_token_cost(
465
+ tokens=run_usage.input_tokens,
466
+ rate=self.input_tokens,
467
+ exchange_rates=exchange_rates,
468
+ currency=currency,
469
+ )
470
+ + self._calculate_token_cost(
471
+ tokens=run_usage.cache_write_tokens,
472
+ rate=self.cache_write_tokens,
473
+ exchange_rates=exchange_rates,
474
+ currency=currency,
475
+ )
476
+ + self._calculate_token_cost(
477
+ tokens=run_usage.cache_read_tokens,
478
+ rate=self.cache_read_tokens,
479
+ exchange_rates=exchange_rates,
480
+ currency=currency,
481
+ )
482
+ + self._calculate_token_cost(
483
+ tokens=run_usage.output_tokens,
484
+ rate=self.output_tokens,
485
+ exchange_rates=exchange_rates,
486
+ currency=currency,
487
+ )
488
+ + self._calculate_token_cost(
489
+ tokens=run_usage.input_audio_tokens,
490
+ rate=self.input_audio_tokens,
491
+ exchange_rates=exchange_rates,
492
+ currency=currency,
493
+ )
494
+ + self._calculate_token_cost(
495
+ tokens=run_usage.cache_audio_read_tokens,
496
+ rate=self.cache_audio_read_tokens,
497
+ exchange_rates=exchange_rates,
498
+ currency=currency,
499
+ )
500
+ + self._calculate_token_cost(
501
+ tokens=run_usage.output_audio_tokens,
502
+ rate=self.output_audio_tokens,
503
+ exchange_rates=exchange_rates,
504
+ currency=currency,
505
+ )
506
+ )
507
+
508
+ @staticmethod
509
+ def _calculate_token_cost(
510
+ *,
511
+ tokens: int,
512
+ rate: Money,
513
+ exchange_rates: list[ExchangeRateInUsd],
514
+ currency: Currency,
515
+ ) -> Decimal:
516
+ """Calculate one token-field cost from a per-million rate.
517
+
518
+ Step by step:
519
+ 1. convert the rate into the response currency through `Money.exchange_to`;
520
+ 2. divide token count by one million because rates are per-million;
521
+ 3. multiply with Decimal values to preserve sub-cent totals.
522
+ """
523
+
524
+ rate_amount = _convert_money_true_amount(
525
+ money=rate,
526
+ exchange_rates=exchange_rates,
527
+ currency=currency,
528
+ )
529
+ return rate_amount * Decimal(tokens) / _MILLION
530
+
531
+
532
+ class TaskCostBase(SchemaBase):
533
+ """Durable task-cost row stored separately from `TaskMongoObject`.
534
+
535
+ Step by step:
536
+ 1. keep cost accounting in its own collection so task state remains compact;
537
+ 2. denormalize task scope fields required by admin cost reports;
538
+ 3. store raw `RunUsage`, token rates, extra spend, timing, retry, and metadata.
539
+ """
540
+
541
+ task_id: ObjectId
542
+ root_task: ObjectId
543
+ owner: str
544
+ task_type: str
545
+ source: UsageAccountingSource | None = None
546
+ tags: list[str] = Field(default_factory=list)
547
+ run_usage: RunUsage
548
+ token_rates_per_million: TaskCostTokenRates = Field(
549
+ default_factory=TaskCostTokenRates
550
+ )
551
+ additional_cost: Money = Field(default_factory=_zero_usd_money)
552
+ seconds: float = Field(default=0, ge=0)
553
+ retry: int = Field(default=0, ge=0)
554
+ occurred_at: datetime = Field(default_factory=utc_now)
555
+ metadata: TaskCostMetadata = Field(default_factory=dict)
556
+
557
+ @field_validator("tags")
558
+ @classmethod
559
+ def _normalize_tags(cls, value: list[str]) -> list[str]:
560
+ """Normalize tag filters before persistence and aggregation.
561
+
562
+ Step by step:
563
+ 1. trim whitespace from each tag;
564
+ 2. drop empty tags because `untagged` is derived at aggregation time;
565
+ 3. preserve first-seen order while removing duplicates.
566
+ """
567
+
568
+ tags: list[str] = []
569
+ seen: set[str] = set()
570
+ for tag in value:
571
+ normalized = tag.strip()
572
+ if not normalized:
573
+ continue
574
+ if normalized in seen:
575
+ continue
576
+ seen.add(normalized)
577
+ tags.append(normalized)
578
+ return tags
579
+
580
+ @field_validator("occurred_at")
581
+ @classmethod
582
+ def _normalize_occurred_at(cls, value: datetime) -> datetime:
583
+ """Store occurrence timestamps as timezone-aware UTC datetimes."""
584
+
585
+ return ensure_aware_utc(value)
586
+
587
+ @field_validator("metadata", mode="before")
588
+ @classmethod
589
+ def _normalize_metadata(cls, value: object) -> TaskCostMetadata:
590
+ """Normalize cost metadata before storing it."""
591
+
592
+ return build_task_cost_metadata(value)
593
+
594
+ @property
595
+ def total_tokens(self) -> int:
596
+ """Return the complete token count represented by `run_usage`."""
597
+
598
+ return (
599
+ self.run_usage.input_tokens
600
+ + self.run_usage.cache_write_tokens
601
+ + self.run_usage.cache_read_tokens
602
+ + self.run_usage.output_tokens
603
+ + self.run_usage.input_audio_tokens
604
+ + self.run_usage.cache_audio_read_tokens
605
+ + self.run_usage.output_audio_tokens
606
+ )
607
+
608
+ def calculate_total_cost(
609
+ self,
610
+ *,
611
+ exchange_rates: list[ExchangeRateInUsd],
612
+ currency: Currency,
613
+ ) -> Decimal:
614
+ """Calculate this row's total cost in the requested currency.
615
+
616
+ Step by step:
617
+ 1. calculate token spend from `RunUsage` and per-million rates;
618
+ 2. convert additional non-token spend through the same exchange table;
619
+ 3. return the Decimal sum without cent-rounding.
620
+ """
621
+
622
+ token_cost = self.token_rates_per_million.calculate_usage_cost(
623
+ run_usage=self.run_usage,
624
+ exchange_rates=exchange_rates,
625
+ currency=currency,
626
+ )
627
+ additional_cost = _convert_money_true_amount(
628
+ money=self.additional_cost,
629
+ exchange_rates=exchange_rates,
630
+ currency=currency,
631
+ )
632
+ return token_cost + additional_cost
633
+
634
+
635
+ class TaskCostCreate(TaskCostBase):
636
+ """Create payload for one durable task-cost row.
637
+
638
+ Step by step:
639
+ 1. accept the stored task row as the source of owner/task-type truth;
640
+ 2. accept the caller-resolved root task id used for workspace reports;
641
+ 3. combine usage, rates, extra cost, timing, retry, and metadata into the
642
+ durable cost-row contract.
643
+ """
644
+
645
+ @classmethod
646
+ def from_task_usage(
647
+ cls,
648
+ *,
649
+ task: TaskMongoObject,
650
+ root_task: ObjectId,
651
+ tags: list[str] | None,
652
+ run_usage: RunUsage,
653
+ source: UsageAccountingSource | None = None,
654
+ token_rates_per_million: TaskCostTokenRates | None = None,
655
+ additional_cost: Money | None = None,
656
+ seconds: float = 0,
657
+ retry: int = 0,
658
+ occurred_at: datetime | None = None,
659
+ metadata: TaskCostMetadata | None = None,
660
+ ) -> "TaskCostCreate":
661
+ """Build one create row from a persisted task and usage payload.
662
+
663
+ Step by step:
664
+ 1. denormalize `owner` and `task_type` from the stored task row;
665
+ 2. normalize omitted rates and additional spend to zero-USD values;
666
+ 3. preserve caller-provided timing, retry, occurrence time, tags, and
667
+ metadata for later list and aggregate reporting.
668
+ """
669
+
670
+ return cls(
671
+ task_id=task.id,
672
+ root_task=root_task,
673
+ owner=task.owner,
674
+ task_type=task.task_type,
675
+ source=source,
676
+ tags=tags or [],
677
+ run_usage=run_usage,
678
+ token_rates_per_million=token_rates_per_million or TaskCostTokenRates(),
679
+ additional_cost=additional_cost or Money(
680
+ float_money=0,
681
+ currency=Currency.USD,
682
+ ),
683
+ seconds=seconds,
684
+ retry=retry,
685
+ occurred_at=occurred_at or utc_now(),
686
+ metadata=metadata or {},
687
+ )
688
+
689
+
690
+ class TaskCostMongoObject(MongoObjectBase, TaskCostBase):
691
+ """Mongo representation of one task-cost row."""
692
+
693
+
694
+ class TaskCostFilterBase(BaseModel):
695
+ """Canonical indexed filter contract for task-cost reads.
696
+
697
+ Step by step:
698
+ 1. own every field shared by list and aggregate task-cost reads;
699
+ 2. normalize tag and time filters once before CRUD receives them;
700
+ 3. convert the shared contract into the Mongo filter used by indexed reads.
701
+ """
702
+
703
+ model_config = PydanticModelConfig.default()
704
+
705
+ task_id: ObjectId | None = None
706
+ root_task: ObjectId | None = None
707
+ owner: str | None = None
708
+ task_types: list[str] | None = None
709
+ sources: list[UsageAccountingSource] | None = None
710
+ tags: list[str] | None = None
711
+ start_at: datetime | None = None
712
+ end_at: datetime | None = None
713
+
714
+ @field_validator("sources")
715
+ @classmethod
716
+ def _normalize_sources(
717
+ cls,
718
+ value: list[UsageAccountingSource] | None,
719
+ ) -> list[UsageAccountingSource] | None:
720
+ """Normalize source filters while preserving caller order."""
721
+
722
+ if value is None:
723
+ return None
724
+ sources: list[UsageAccountingSource] = []
725
+ seen: set[UsageAccountingSource] = set()
726
+ for source in value:
727
+ if source in seen:
728
+ continue
729
+ sources.append(source)
730
+ seen.add(source)
731
+ return sources
732
+
733
+ @field_validator("tags")
734
+ @classmethod
735
+ def _normalize_tags(cls, value: list[str] | None) -> list[str] | None:
736
+ """Normalize all-tags filters while preserving omitted filters."""
737
+
738
+ if value is None:
739
+ return None
740
+ return TaskCostBase._normalize_tags(value)
741
+
742
+ @field_validator("start_at", "end_at")
743
+ @classmethod
744
+ def _normalize_time_filter(cls, value: datetime | None) -> datetime | None:
745
+ """Normalize optional time filters to timezone-aware UTC datetimes."""
746
+
747
+ if value is None:
748
+ return None
749
+ return ensure_aware_utc(value)
750
+
751
+ def to_mongo_filters(self) -> dict[str, Any]:
752
+ """Convert this canonical filter into an indexed Mongo query.
753
+
754
+ Step by step:
755
+ 1. map optional equality filters onto stored task-cost fields;
756
+ 2. apply `tags` with `$all` so every requested tag must be present;
757
+ 3. apply `start_at <= occurred_at < end_at` for occurrence windows.
758
+ """
759
+
760
+ filters: dict[str, Any] = {}
761
+ if self.task_id is not None:
762
+ filters["task_id"] = self.task_id
763
+ if self.root_task is not None:
764
+ filters["root_task"] = self.root_task
765
+ if self.owner is not None:
766
+ filters["owner"] = self.owner
767
+ if self.task_types is not None:
768
+ filters["task_type"] = {"$in": self.task_types}
769
+ if self.sources is not None:
770
+ filters["source"] = {"$in": self.sources}
771
+ if self.tags:
772
+ filters["tags"] = {"$all": self.tags}
773
+ occurred_at_filter: dict[str, datetime] = {}
774
+ if self.start_at is not None:
775
+ occurred_at_filter["$gte"] = self.start_at
776
+ if self.end_at is not None:
777
+ occurred_at_filter["$lt"] = self.end_at
778
+ if occurred_at_filter:
779
+ filters["occurred_at"] = occurred_at_filter
780
+ return filters
781
+
782
+
783
+ class TaskCostListRequest(TaskCostFilterBase):
784
+ """Paginated task-cost list request using canonical read filters.
785
+
786
+ Step by step:
787
+ 1. inherit every filter field from `TaskCostFilterBase`;
788
+ 2. keep list-only pagination fields on this request schema;
789
+ 3. leave Mongo filter construction to the shared base contract.
790
+ """
791
+
792
+ model_config = PydanticModelConfig.default()
793
+
794
+ skip: int = Field(default=0, ge=0)
795
+ limit: int = Field(default=100, ge=1, le=1000)
796
+
797
+
798
+ class TaskCostListResponse(BaseModel):
799
+ """Paginated task-cost list response."""
800
+
801
+ model_config = PydanticModelConfig.default()
802
+
803
+ costs: list[TaskCostMongoObject]
804
+ total: int
805
+ skip: int
806
+ limit: int
807
+
808
+
809
+ class TaskCostAggregateRequest(TaskCostFilterBase):
810
+ """Task-cost aggregation request using canonical read filters.
811
+
812
+ Step by step:
813
+ 1. inherit every filter field from `TaskCostFilterBase`;
814
+ 2. choose one time bucket or `TOTAL` for the whole filtered period;
815
+ 3. optionally fan each row into tag buckets for tag-level reporting.
816
+ """
817
+
818
+ model_config = PydanticModelConfig.default()
819
+
820
+ bucket: TaskCostTimeBucket = TaskCostTimeBucket.TOTAL
821
+ group_by: TaskCostAggregateGroupBy = TaskCostAggregateGroupBy.TOTAL
822
+ group_by_tag: bool = False
823
+ currency: Currency = Currency.USD
824
+
825
+ @model_validator(mode="after")
826
+ def _normalize_group_by(self) -> "TaskCostAggregateRequest":
827
+ """Map legacy tag grouping onto the canonical aggregate enum."""
828
+
829
+ if self.group_by_tag and self.group_by is TaskCostAggregateGroupBy.TOTAL:
830
+ self.group_by = TaskCostAggregateGroupBy.TAG
831
+ if self.group_by_tag and self.group_by is not TaskCostAggregateGroupBy.TAG:
832
+ raise ValueError("group_by_tag=True is only compatible with group_by=TAG")
833
+ self.group_by_tag = self.group_by is TaskCostAggregateGroupBy.TAG
834
+ return self
835
+
836
+
837
+ class TaskCostAggregateBucket(BaseModel):
838
+ """One aggregated task-cost bucket.
839
+
840
+ Step by step:
841
+ 1. identify the optional time and tag bucket;
842
+ 2. expose precise Decimal cost in the requested currency;
843
+ 3. include counts, token totals, seconds, and retries for diagnostics.
844
+ """
845
+
846
+ model_config = PydanticModelConfig.default()
847
+
848
+ start_at: datetime | None = None
849
+ end_at: datetime | None = None
850
+ tag: str | None = None
851
+ source: UsageAccountingSource | None = None
852
+ task_type: str | None = None
853
+ currency: Currency
854
+ total_cost: Decimal
855
+ count: int
856
+ input_tokens: int
857
+ output_tokens: int
858
+ total_tokens: int
859
+ seconds: float
860
+ retry: int
861
+
862
+ @classmethod
863
+ def empty(
864
+ cls,
865
+ *,
866
+ start_at: datetime | None,
867
+ end_at: datetime | None,
868
+ tag: str | None,
869
+ source: UsageAccountingSource | None,
870
+ task_type: str | None,
871
+ currency: Currency,
872
+ ) -> "TaskCostAggregateBucket":
873
+ """Open one zeroed bucket for a (time, tag, source, task_type) key.
874
+
875
+ Step by step:
876
+ 1. fix the bucket identity (window + optional group keys + currency);
877
+ 2. start every running total at a typed zero so `accumulate` can fold rows;
878
+ 3. keep bucket creation on the type that owns the aggregate contract.
879
+ """
880
+
881
+ return cls(
882
+ start_at=start_at,
883
+ end_at=end_at,
884
+ tag=tag,
885
+ source=source,
886
+ task_type=task_type,
887
+ currency=currency,
888
+ total_cost=Decimal("0"),
889
+ count=0,
890
+ input_tokens=0,
891
+ output_tokens=0,
892
+ total_tokens=0,
893
+ seconds=0.0,
894
+ retry=0,
895
+ )
896
+
897
+ def accumulate(self, *, cost: "TaskCostBase", row_cost: Decimal) -> None:
898
+ """Fold one already-converted cost row into this bucket's running totals.
899
+
900
+ Step by step:
901
+ 1. add the precomputed `row_cost` (already in the bucket currency);
902
+ 2. advance counts, token totals, seconds, and retries from the row;
903
+ 3. mutate in place so the bucket stays the single source of bucket math.
904
+ """
905
+
906
+ self.total_cost = self.total_cost + row_cost
907
+ self.count = self.count + 1
908
+ self.input_tokens = self.input_tokens + cost.run_usage.input_tokens
909
+ self.output_tokens = self.output_tokens + cost.run_usage.output_tokens
910
+ self.total_tokens = self.total_tokens + cost.total_tokens
911
+ self.seconds = self.seconds + cost.seconds
912
+ self.retry = self.retry + cost.retry
913
+
914
+
915
+ class TaskCostAggregateResponse(BaseModel):
916
+ """Task-cost aggregate response for admin reporting.
917
+
918
+ Step by step:
919
+ 1. receive already-filtered task-cost rows from CRUD;
920
+ 2. calculate row spend in the requested currency;
921
+ 3. group rows by requested time bucket and optional tag bucket.
922
+ """
923
+
924
+ model_config = PydanticModelConfig.default()
925
+
926
+ bucket: TaskCostTimeBucket
927
+ group_by: TaskCostAggregateGroupBy
928
+ group_by_tag: bool
929
+ currency: Currency
930
+ buckets: list[TaskCostAggregateBucket]
931
+
932
+ @classmethod
933
+ def build(
934
+ cls,
935
+ *,
936
+ costs: Sequence[TaskCostBase],
937
+ query: TaskCostAggregateRequest,
938
+ exchange_rates: list[ExchangeRateInUsd],
939
+ ) -> "TaskCostAggregateResponse":
940
+ """Aggregate cost rows into the public admin response contract.
941
+
942
+ Step by step:
943
+ 1. resolve the time and tag bucket keys for every row;
944
+ 2. fold precise Decimal cost, counts, tokens, seconds, and retries into
945
+ the typed `TaskCostAggregateBucket` that owns the bucket math;
946
+ 3. sort the typed buckets deterministically for stable API responses.
947
+
948
+ `costs` is a read-only `Sequence` so `list[TaskCostMongoObject]` from CRUD
949
+ is accepted without invariance errors (build never mutates the input list).
950
+ """
951
+
952
+ bucket_data: dict[
953
+ tuple[
954
+ datetime | None,
955
+ str | None,
956
+ UsageAccountingSource | None,
957
+ str | None,
958
+ ],
959
+ TaskCostAggregateBucket,
960
+ ] = {}
961
+ for cost in costs:
962
+ start_at = cls._bucket_start(
963
+ occurred_at=cost.occurred_at,
964
+ bucket=query.bucket,
965
+ )
966
+ row_cost = cost.calculate_total_cost(
967
+ exchange_rates=exchange_rates,
968
+ currency=query.currency,
969
+ )
970
+ for tag, source, task_type in cls._aggregate_group_values(
971
+ cost=cost,
972
+ group_by=query.group_by,
973
+ ):
974
+ key = (start_at, tag, source, task_type)
975
+ if key not in bucket_data:
976
+ bucket_data[key] = TaskCostAggregateBucket.empty(
977
+ start_at=start_at,
978
+ end_at=cls._bucket_end(
979
+ start_at=start_at,
980
+ bucket=query.bucket,
981
+ ),
982
+ tag=tag,
983
+ source=source,
984
+ task_type=task_type,
985
+ currency=query.currency,
986
+ )
987
+ bucket_data[key].accumulate(cost=cost, row_cost=row_cost)
988
+ return cls(
989
+ bucket=query.bucket,
990
+ group_by=query.group_by,
991
+ group_by_tag=query.group_by_tag,
992
+ currency=query.currency,
993
+ buckets=[
994
+ bucket
995
+ for _, bucket in sorted(
996
+ bucket_data.items(),
997
+ key=lambda item: cls._bucket_sort_key(key=item[0]),
998
+ )
999
+ ],
1000
+ )
1001
+
1002
+ @staticmethod
1003
+ def _bucket_start(
1004
+ *,
1005
+ occurred_at: datetime,
1006
+ bucket: TaskCostTimeBucket,
1007
+ ) -> datetime | None:
1008
+ """Resolve the inclusive start timestamp for one aggregate bucket."""
1009
+
1010
+ if bucket is TaskCostTimeBucket.TOTAL:
1011
+ return None
1012
+ if bucket is TaskCostTimeBucket.HOUR:
1013
+ return occurred_at.replace(minute=0, second=0, microsecond=0)
1014
+ if bucket is TaskCostTimeBucket.DAY:
1015
+ return occurred_at.replace(hour=0, minute=0, second=0, microsecond=0)
1016
+ if bucket is TaskCostTimeBucket.WEEK:
1017
+ day_start = occurred_at.replace(
1018
+ hour=0,
1019
+ minute=0,
1020
+ second=0,
1021
+ microsecond=0,
1022
+ )
1023
+ return day_start - timedelta(days=day_start.weekday())
1024
+ return occurred_at.replace(
1025
+ day=1,
1026
+ hour=0,
1027
+ minute=0,
1028
+ second=0,
1029
+ microsecond=0,
1030
+ )
1031
+
1032
+ @staticmethod
1033
+ def _bucket_end(
1034
+ *,
1035
+ start_at: datetime | None,
1036
+ bucket: TaskCostTimeBucket,
1037
+ ) -> datetime | None:
1038
+ """Resolve the exclusive end timestamp for one aggregate bucket."""
1039
+
1040
+ if start_at is None:
1041
+ return None
1042
+ if bucket is TaskCostTimeBucket.HOUR:
1043
+ return start_at + timedelta(hours=1)
1044
+ if bucket is TaskCostTimeBucket.DAY:
1045
+ return start_at + timedelta(days=1)
1046
+ if bucket is TaskCostTimeBucket.WEEK:
1047
+ return start_at + timedelta(days=7)
1048
+ if start_at.month == 12:
1049
+ return start_at.replace(year=start_at.year + 1, month=1)
1050
+ return start_at.replace(month=start_at.month + 1)
1051
+
1052
+ @staticmethod
1053
+ def _aggregate_group_values(
1054
+ *,
1055
+ cost: TaskCostBase,
1056
+ group_by: TaskCostAggregateGroupBy,
1057
+ ) -> list[tuple[str | None, UsageAccountingSource | None, str | None]]:
1058
+ """Resolve the aggregate grouping values for one cost row."""
1059
+
1060
+ if group_by is TaskCostAggregateGroupBy.TAG:
1061
+ if len(cost.tags) == 0:
1062
+ return [("untagged", None, None)]
1063
+ return [(tag, None, None) for tag in cost.tags]
1064
+ if group_by is TaskCostAggregateGroupBy.SOURCE:
1065
+ return [(None, cost.source, None)]
1066
+ if group_by is TaskCostAggregateGroupBy.TASK_TYPE:
1067
+ return [(None, None, cost.task_type)]
1068
+ return [(None, None, None)]
1069
+
1070
+ @staticmethod
1071
+ def _bucket_sort_key(
1072
+ *,
1073
+ key: tuple[
1074
+ datetime | None,
1075
+ str | None,
1076
+ UsageAccountingSource | None,
1077
+ str | None,
1078
+ ],
1079
+ ) -> tuple[datetime, str, str, str]:
1080
+ """Build a stable sort key for aggregate response buckets."""
1081
+
1082
+ start_at, tag, source, task_type = key
1083
+ normalized_start_at = start_at or datetime.min.replace(tzinfo=timezone.utc)
1084
+ return normalized_start_at, tag or "", source.value if source else "", task_type or ""
1085
+
1086
+
1087
+ def _flatten_task_room_graph_tasks(
1088
+ *, task: TaskMongoObjectExtended
1089
+ ) -> list["TaskRoomGraphTask"]:
1090
+ """Flatten one task tree into prompt-safe room graph task nodes.
1091
+
1092
+ Step by step:
1093
+ 1. convert the current task row into the compact graph shape;
1094
+ 2. recurse through persisted child task rows in stored order;
1095
+ 3. return a stable pre-order list so prompts read parent before child.
1096
+ """
1097
+
1098
+ flattened = [
1099
+ TaskRoomGraphTask(
1100
+ task_id=task.id,
1101
+ parent_task_id=task.parent_task,
1102
+ task_type=task.task_type,
1103
+ title=task.title,
1104
+ subtitle=task.subtitle,
1105
+ )
1106
+ ]
1107
+ for child in task.childs:
1108
+ flattened.extend(_flatten_task_room_graph_tasks(task=child))
1109
+ return flattened
1110
+
1111
+
1112
+ def _summarize_task_relation_metadata(
1113
+ *, metadata: dict[str, Any]
1114
+ ) -> dict[str, str]:
1115
+ """Convert relation metadata into compact scalar prompt strings.
1116
+
1117
+ Step by step:
1118
+ 1. keep simple scalar values because they are useful for agent routing;
1119
+ 2. summarize nested values by shape instead of dumping raw payloads;
1120
+ 3. truncate long scalar text so relation lines stay compact.
1121
+ """
1122
+
1123
+ summary: dict[str, str] = {}
1124
+ for key, value in metadata.items():
1125
+ if isinstance(value, dict):
1126
+ summary[key] = f"dict({len(value)} keys)"
1127
+ continue
1128
+ if isinstance(value, list):
1129
+ summary[key] = f"list({len(value)} items)"
1130
+ continue
1131
+ text = "null" if value is None else str(value)
1132
+ summary[key] = text[:117] + "..." if len(text) > 120 else text
1133
+ return summary
1134
+
1135
+
1136
+ def _format_task_room_metadata_summary(*, metadata: dict[str, str]) -> str:
1137
+ """Render compact relation metadata in stable key order.
1138
+
1139
+ Step by step:
1140
+ 1. return a fixed empty marker when no metadata exists;
1141
+ 2. sort metadata keys so prompt diffs remain stable;
1142
+ 3. join key/value pairs into one relation-line suffix.
1143
+ """
1144
+
1145
+ if len(metadata) == 0:
1146
+ return "{}"
1147
+ return "{" + ", ".join(f"{key}={metadata[key]}" for key in sorted(metadata)) + "}"
1148
+
1149
+
1150
+ class TaskRoomGraphTask(SchemaBase):
1151
+ """Compact task node used to explain one root-task room to agents."""
1152
+
1153
+ task_id: ObjectId
1154
+ parent_task_id: ObjectId | None = None
1155
+ task_type: str
1156
+ title: str | None = None
1157
+ subtitle: str | None = None
1158
+
1159
+ def to_prompt_line(self) -> str:
1160
+ """Render one task node as a compact, stable prompt line.
1161
+
1162
+ Step by step:
1163
+ 1. expose ids and task type for deterministic agent references;
1164
+ 2. render the parent edge inline so tree structure is visible;
1165
+ 3. include only title/subtitle display text when present.
1166
+ """
1167
+
1168
+ parent_text = str(self.parent_task_id) if self.parent_task_id else "ROOT"
1169
+ parts = [
1170
+ f"- task {self.task_id}",
1171
+ f"type={self.task_type}",
1172
+ f"parent={parent_text}",
1173
+ ]
1174
+ if self.title:
1175
+ parts.append(f"title={self.title}")
1176
+ if self.subtitle:
1177
+ parts.append(f"subtitle={self.subtitle}")
1178
+ return " | ".join(parts)
1179
+
1180
+
1181
+ class TaskRoomGraphRelation(SchemaBase):
1182
+ """Compact non-tree relation edge inside one root-task room."""
1183
+
1184
+ from_task_id: ObjectId
1185
+ to_task_id: ObjectId
1186
+ relation_type: TaskRelationType
1187
+ metadata_summary: dict[str, str] = Field(default_factory=dict)
1188
+
1189
+ @classmethod
1190
+ def build(cls, *, relation: TaskRelationMongoObject) -> "TaskRoomGraphRelation":
1191
+ """Build one prompt-safe relation edge from the persisted relation row.
1192
+
1193
+ Step by step:
1194
+ 1. copy stable endpoint ids and relation type unchanged;
1195
+ 2. reduce arbitrary metadata to compact scalar strings;
1196
+ 3. return a feature-agnostic edge with no grid or positioning knowledge.
1197
+ """
1198
+
1199
+ return cls(
1200
+ from_task_id=relation.from_task_id,
1201
+ to_task_id=relation.to_task_id,
1202
+ relation_type=relation.relation_type,
1203
+ metadata_summary=_summarize_task_relation_metadata(
1204
+ metadata=relation.metadata
1205
+ ),
1206
+ )
1207
+
1208
+ def to_prompt_line(self) -> str:
1209
+ """Render one relation edge as a compact, stable prompt line.
1210
+
1211
+ Step by step:
1212
+ 1. expose relation type and task endpoint ids;
1213
+ 2. append reduced metadata in sorted order;
1214
+ 3. keep the output single-line for token-efficient room context.
1215
+ """
1216
+
1217
+ metadata_text = _format_task_room_metadata_summary(
1218
+ metadata=self.metadata_summary
1219
+ )
1220
+ return (
1221
+ f"- relation {self.relation_type.value}: "
1222
+ f"{self.from_task_id} -> {self.to_task_id} | metadata={metadata_text}"
1223
+ )
1224
+
1225
+
1226
+ class TaskRoomGraphContext(SchemaBase):
1227
+ """Generic task-room graph context; feature packages may enrich it externally."""
1228
+
1229
+ root_task_id: ObjectId
1230
+ focused_task_id: ObjectId
1231
+ tasks: list[TaskRoomGraphTask]
1232
+ relations: list[TaskRoomGraphRelation]
1233
+
1234
+ @classmethod
1235
+ def build(
1236
+ cls,
1237
+ *,
1238
+ root_task: TaskMongoObjectExtended,
1239
+ focused_task_id: ObjectId,
1240
+ relations: list[TaskRelationMongoObject],
1241
+ ) -> "TaskRoomGraphContext":
1242
+ """Build generic graph context from one loaded task tree and relation rows.
1243
+
1244
+ Step by step:
1245
+ 1. flatten the loaded root task tree into prompt-safe task nodes;
1246
+ 2. convert relation rows into compact relation edges;
1247
+ 3. validate that the focused task is present in the same root room.
1248
+ """
1249
+
1250
+ tasks = _flatten_task_room_graph_tasks(task=root_task)
1251
+ task_ids = {task.task_id for task in tasks}
1252
+ if focused_task_id not in task_ids:
1253
+ raise keble_exceptions.ServerSideInvalidParams(
1254
+ admin_note={
1255
+ "root_task_id": root_task.id,
1256
+ "focused_task_id": focused_task_id,
1257
+ },
1258
+ alert_admin=True,
1259
+ but_got="focused task outside loaded root room",
1260
+ expected="focused task belongs to loaded root room",
1261
+ invalid_params="focused_task_id",
1262
+ )
1263
+ return cls(
1264
+ root_task_id=root_task.id,
1265
+ focused_task_id=focused_task_id,
1266
+ tasks=tasks,
1267
+ relations=[
1268
+ TaskRoomGraphRelation.build(relation=relation)
1269
+ for relation in relations
1270
+ ],
1271
+ )
1272
+
1273
+ def to_prompt_text(self) -> str:
1274
+ """Render the generic task-room graph as compact Markdown-like text.
1275
+
1276
+ Step by step:
1277
+ 1. identify the durable root room and current focus;
1278
+ 2. render task nodes before sidecar relation edges;
1279
+ 3. avoid feature-specific grid or positioning fields by design.
1280
+ """
1281
+
1282
+ lines = [
1283
+ "Task Room Graph Context",
1284
+ f"Root task: {self.root_task_id}",
1285
+ f"Focused task: {self.focused_task_id}",
1286
+ "",
1287
+ "Tasks:",
1288
+ ]
1289
+ lines.extend(task.to_prompt_line() for task in self.tasks)
1290
+ lines.extend(["", "Relations:"])
1291
+ if len(self.relations) == 0:
1292
+ lines.append("- none")
1293
+ else:
1294
+ lines.extend(relation.to_prompt_line() for relation in self.relations)
1295
+ return "\n".join(lines)