refactor: extract _SpendingTotals base class from spending summary models#138
refactor: extract _SpendingTotals base class from spending summary models#138
Conversation
…dels (#111) Extract shared aggregation fields (total_cost_usd, total_input_tokens, total_output_tokens, record_count) into a private _SpendingTotals base class. AgentSpending, DepartmentSpending, and PeriodSpending now inherit from it, eliminating ~48 lines of duplicated field definitions. Update DESIGN_SPEC.md §10.2 and §15.5 to reflect the extraction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add instantiation guidance note and align attribute descriptions with Field(description=...) values for consistency. Pre-reviewed by 8 agents, 2 findings addressed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly refactors the spending summary models by introducing a common base class. This change centralizes the definition of shared aggregation fields, reducing duplication and making the codebase more modular and easier to maintain. The refactoring ensures that all related models consistently handle their core spending metrics while allowing for dimension-specific extensions. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis PR extracts a shared Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request is a great refactoring that extracts a _SpendingTotals base class from the spending summary models, effectively reducing code duplication and improving maintainability. The changes are well-executed, and the documentation in DESIGN_SPEC.md has been updated accordingly. I have one suggestion to further improve the robustness of the new base class by programmatically preventing its direct instantiation, which aligns with the intent described in its docstring.
| class _SpendingTotals(BaseModel): | ||
| """Shared aggregation fields for spending summary models. | ||
|
|
||
| Not intended for direct instantiation — subclass with a | ||
| dimension-specific identifier (agent, department, or period). | ||
|
|
||
| Attributes: | ||
| start: Period start (inclusive). | ||
| end: Period end (exclusive). | ||
| total_cost_usd: Total cost for the period. | ||
| total_cost_usd: Total cost for the aggregation group. | ||
| total_input_tokens: Total input tokens consumed. | ||
| total_output_tokens: Total output tokens consumed. | ||
| record_count: Number of cost records aggregated. | ||
| """ |
There was a problem hiding this comment.
The docstring correctly states that _SpendingTotals is not intended for direct instantiation. To enforce this and prevent potential misuse, you can add a check to prevent it from being instantiated directly. This improves the robustness of the design.
Here's how you could do it using model_post_init (you'll also need to from typing import Any):
def model_post_init(self, __context: Any) -> None:
"""Prevent direct instantiation of this base class."""
if type(self) is _SpendingTotals:
raise TypeError(
"_SpendingTotals is a base class and cannot be instantiated directly. "
"Subclass it and add a dimension-specific identifier."
)There was a problem hiding this comment.
Pull request overview
Refactors the budget spending summary Pydantic models to reduce duplication by introducing a shared private base model for common aggregation totals, and updates the design spec to document the new “shared field groups” convention.
Changes:
- Extract
_SpendingTotalsbase model containing shared aggregation fields and frozen config. - Update
PeriodSpending,AgentSpending, andDepartmentSpendingto inherit from_SpendingTotals. - Update DESIGN_SPEC sections (§10.2, §15.5) to reflect the convention as adopted.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/ai_company/budget/spending_summary.py | Introduces _SpendingTotals and refactors spending summary models to inherit shared aggregation fields. |
| DESIGN_SPEC.md | Updates documentation to reflect the new shared-field-group base model convention as adopted. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| total_cost_usd: float = Field( | ||
| default=0.0, | ||
| ge=0.0, | ||
| description="Total cost for the period", | ||
| description="Total cost for the aggregation group", | ||
| ) |
There was a problem hiding this comment.
total_cost_usd Field description has been changed from the per-dimension wording (period/agent/department) to the generic "aggregation group" wording. This alters generated JSON schema / API docs and contradicts the PR claim of "no behavioral changes" if schema/metadata is considered part of the public surface. Either restore the previous per-model descriptions (e.g., by overriding field metadata in subclasses) or update the PR summary/changelog to explicitly call out the schema/doc metadata change.
Greptile SummaryThis PR is a clean structural refactor that extracts a private
Confidence Score: 4/5
Important Files Changed
Class Diagram%%{init: {'theme': 'neutral'}}%%
classDiagram
class _SpendingTotals {
+float total_cost_usd
+int total_input_tokens
+int total_output_tokens
+int record_count
model_config: frozen=True
}
class PeriodSpending {
+datetime start
+datetime end
_validate_period_ordering()
}
class AgentSpending {
+NotBlankStr agent_id
}
class DepartmentSpending {
+NotBlankStr department_name
}
class SpendingSummary {
+PeriodSpending period
+tuple~AgentSpending~ by_agent
+tuple~DepartmentSpending~ by_department
+float budget_total_monthly
+float budget_used_percent
+BudgetAlertLevel alert_level
model_config: frozen=True
_validate_unique_agent_ids()
_validate_unique_department_names()
}
_SpendingTotals <|-- PeriodSpending
_SpendingTotals <|-- AgentSpending
_SpendingTotals <|-- DepartmentSpending
SpendingSummary o-- PeriodSpending
SpendingSummary o-- AgentSpending
SpendingSummary o-- DepartmentSpending
Last reviewed commit: 3fa29a7 |
| @@ -65,78 +76,26 @@ def _validate_period_ordering(self) -> Self: | |||
| return self | |||
|
|
|||
|
|
|||
| class AgentSpending(BaseModel): | |||
| class AgentSpending(_SpendingTotals): | |||
| """Spending aggregation for a single agent. | |||
|
|
|||
| Attributes: | |||
| agent_id: Agent identifier. | |||
| total_cost_usd: Total cost for this agent. | |||
| total_input_tokens: Total input tokens consumed. | |||
| total_output_tokens: Total output tokens consumed. | |||
| record_count: Number of cost records. | |||
| """ | |||
|
|
|||
| model_config = ConfigDict(frozen=True) | |||
|
|
|||
| agent_id: NotBlankStr = Field(description="Agent identifier") | |||
| total_cost_usd: float = Field( | |||
| default=0.0, | |||
| ge=0.0, | |||
| description="Total cost for this agent", | |||
| ) | |||
| total_input_tokens: int = Field( | |||
| default=0, | |||
| ge=0, | |||
| description="Total input tokens consumed", | |||
| ) | |||
| total_output_tokens: int = Field( | |||
| default=0, | |||
| ge=0, | |||
| description="Total output tokens consumed", | |||
| ) | |||
| record_count: int = Field( | |||
| default=0, | |||
| ge=0, | |||
| description="Number of cost records", | |||
| ) | |||
|
|
|||
|
|
|||
| class DepartmentSpending(BaseModel): | |||
| class DepartmentSpending(_SpendingTotals): | |||
| """Spending aggregation for a department. | |||
|
|
|||
| Attributes: | |||
| department_name: Department name. | |||
| total_cost_usd: Total cost for this department. | |||
| total_input_tokens: Total input tokens consumed. | |||
| total_output_tokens: Total output tokens consumed. | |||
| record_count: Number of cost records. | |||
| """ | |||
|
|
|||
| model_config = ConfigDict(frozen=True) | |||
|
|
|||
| department_name: NotBlankStr = Field( | |||
| description="Department name", | |||
| ) | |||
There was a problem hiding this comment.
JSON serialization field order has changed
The refactor silently changes the field ordering in model_dump() / model_dump_json() output for all three subclasses. Pydantic v2 emits fields in definition order — parent fields first, then subclass fields — so the identifier fields (agent_id, department_name, start/end) are now serialized last instead of first.
Before (e.g. AgentSpending):
{"agent_id": "alice", "total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0}After:
{"total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0, "agent_id": "alice"}Semantically this is harmless for roundtrip deserialization (Pydantic and most JSON consumers are order-agnostic), but the PR description claims "no behavioral changes." The change is real, and it could surface in:
- snapshot / golden-file tests that do string-level JSON comparisons
- downstream consumers that parse the JSON positionally (e.g. some CSV-style streaming parsers)
- OpenAPI schema generation, where field order affects documentation readability
If preserving the original order matters, one lightweight fix is to redeclare the inherited dimension-specific field at the top of each subclass (with no body change) — Pydantic respects the MRO order and moves a redeclared field to where it's first seen in the subclass. Otherwise, consider adding an explicit note to the PR description and/or DESIGN_SPEC acknowledging the order change.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/budget/spending_summary.py
Line: 56-98
Comment:
**JSON serialization field order has changed**
The refactor silently changes the field ordering in `model_dump()` / `model_dump_json()` output for all three subclasses. Pydantic v2 emits fields in definition order — parent fields first, then subclass fields — so the identifier fields (`agent_id`, `department_name`, `start`/`end`) are now serialized *last* instead of first.
Before (e.g. `AgentSpending`):
```json
{"agent_id": "alice", "total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0}
```
After:
```json
{"total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0, "agent_id": "alice"}
```
Semantically this is harmless for roundtrip deserialization (Pydantic and most JSON consumers are order-agnostic), but the PR description claims "no behavioral changes." The change is real, and it could surface in:
- snapshot / golden-file tests that do string-level JSON comparisons
- downstream consumers that parse the JSON positionally (e.g. some CSV-style streaming parsers)
- OpenAPI schema generation, where field order affects documentation readability
If preserving the original order matters, one lightweight fix is to redeclare the inherited dimension-specific field at the top of each subclass (with no body change) — Pydantic respects the MRO order and moves a redeclared field to where it's first seen in the subclass. Otherwise, consider adding an explicit note to the PR description and/or DESIGN_SPEC acknowledging the order change.
How can I resolve this? If you propose a fix, please make it concise.- Soften module docstring CFO agent reference (M5+ feature, not yet built) - Update DESIGN_SPEC §15.3 spending_summary.py comment to mention _SpendingTotals Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| @@ -53,6 +53,18 @@ class PeriodSpending(BaseModel): | |||
| description="Number of cost records aggregated", | |||
| ) | |||
There was a problem hiding this comment.
_SpendingTotals is directly instantiable
The docstring states "Not intended for direct instantiation", but because all four fields carry defaults (0.0 / 0), the class can be silently instantiated without error:
t = _SpendingTotals() # works — produces a dimensionless totals object
t = _SpendingTotals(total_cost_usd=9.99) # also worksThe underscore prefix communicates the intent by convention, but nothing prevents accidental use. Consider enforcing the constraint by raising in __init_subclass__ or, more idiomatically with Pydantic v2, by overriding model_post_init:
def model_post_init(self, __context: object) -> None:
if type(self) is _SpendingTotals:
raise TypeError(
"_SpendingTotals is not intended for direct instantiation; "
"use AgentSpending, DepartmentSpending, or PeriodSpending instead."
)This turns a documentation-only contract into a runtime-enforced one, which is particularly valuable given the class lives in a public package with three concrete subclasses already defined.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/budget/spending_summary.py
Line: 20-54
Comment:
**`_SpendingTotals` is directly instantiable**
The docstring states *"Not intended for direct instantiation"*, but because all four fields carry defaults (`0.0` / `0`), the class can be silently instantiated without error:
```python
t = _SpendingTotals() # works — produces a dimensionless totals object
t = _SpendingTotals(total_cost_usd=9.99) # also works
```
The underscore prefix communicates the intent by convention, but nothing prevents accidental use. Consider enforcing the constraint by raising in `__init_subclass__` or, more idiomatically with Pydantic v2, by overriding `model_post_init`:
```python
def model_post_init(self, __context: object) -> None:
if type(self) is _SpendingTotals:
raise TypeError(
"_SpendingTotals is not intended for direct instantiation; "
"use AgentSpending, DepartmentSpending, or PeriodSpending instead."
)
```
This turns a documentation-only contract into a runtime-enforced one, which is particularly valuable given the class lives in a public package with three concrete subclasses already defined.
How can I resolve this? If you propose a fix, please make it concise.
Summary
_SpendingTotalsbase class with shared aggregation fields (total_cost_usd,total_input_tokens,total_output_tokens,record_count)AgentSpending,DepartmentSpending, andPeriodSpendingnow extend_SpendingTotalsinstead of independently defining identical fieldsCloses #111
Details
_SpendingTotalsis private (underscore-prefixed, excluded from__init__.pyexports)model_config = ConfigDict(frozen=True)defined once on base, inherited by all subclassesPeriodSpending._validate_period_orderingvalidator preserved unchangedge=0, defaults) remain identicalTest plan
spending_summary.pymaintains 100% coveragetest_frozentests on each subclassReview coverage
Pre-reviewed by 8 agents (code-reviewer, python-reviewer, pr-test-analyzer, comment-analyzer, type-design-analyzer, logging-audit, resilience-audit, docs-consistency). 6 findings total, 2 implemented, 4 skipped as non-applicable or out-of-scope.