Skip to content
Merged
18 changes: 18 additions & 0 deletions service/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,21 @@ db-current:
.PHONY: db-history
db-history:
uv run --active alembic history

# Both targets create versions/YYYYMMDD_NN_<message>.py with revision ID YYYYMMDD_NN (next free NN for today).
MIGRATIONS_VERSIONS_DIR := src/ai_document_plugin_service/migrations/versions
NEXT_REVISION_ID = $(shell day=$$(date +%Y%m%d); \
count=$$(find $(MIGRATIONS_VERSIONS_DIR) -maxdepth 1 -name "$${day}_*.py" | wc -l); \
printf '%s_%02d' "$$day" $$((count + 1)))

# Usage: make db-revision m="Add foo to bar" (empty migration)
.PHONY: db-revision
db-revision:
@test -n "$(m)" || { echo 'Usage: make db-revision m="Describe the change"'; exit 1; }
uv run --active alembic revision --rev-id "$(NEXT_REVISION_ID)" -m "$(m)"

# Usage: make db-autogenerate m="Add foo to bar" (diffs schema.py against the DB; DB must be at head)
.PHONY: db-autogenerate
db-autogenerate:
@test -n "$(m)" || { echo 'Usage: make db-autogenerate m="Describe the change"'; exit 1; }
uv run --active alembic revision --autogenerate --rev-id "$(NEXT_REVISION_ID)" -m "$(m)"
3 changes: 3 additions & 0 deletions service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ The project `Makefile` provides a few shortcuts for common development tasks:
- `make db-migrate` applies all Alembic migrations to the configured database
- `make db-current` shows the current Alembic revision stored in the database
- `make db-history` shows available Alembic migration history
- `make db-revision m="Add foo to bar"` creates a new migration `YYYYMMDD_NN_add_foo_to_bar.py` with revision ID
`YYYYMMDD_NN`. Migrations are upgrade-only: there are no `downgrade()` functions, so undo a change with a new
migration

Run these commands from the [service](/Users/hana/DSW/AI-playground/ai-document-plugin/service:1) directory.

Expand Down
4 changes: 4 additions & 0 deletions service/src/ai_document_plugin_service/ai/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from .logging_utils import configure_logging
from .pipeline_metrics import (
PipelineMetricsCollector,
PipelineStats,
StepUsage,
get_component_markdown,
get_component_stats,
)
Expand All @@ -12,6 +14,8 @@
'AssignmentStats',
'Config',
'PipelineMetricsCollector',
'PipelineStats',
'StepUsage',
'call_with_retry',
'configure_logging',
'extract_usage_tokens',
Expand Down
102 changes: 37 additions & 65 deletions service/src/ai_document_plugin_service/ai/common/pipeline_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from dataclasses import dataclass, field

from ai_document_plugin_service.ai.common.types import AssignmentStats
from ai_document_plugin_service.ai.persistence.assignment_saver_component import JsonValue


@dataclass(frozen=True)
Expand All @@ -12,45 +11,70 @@ class PipelineMetricStep:
stats: AssignmentStats


@dataclass(frozen=True)
class StepUsage:
"""LLM usage of one pipeline step in a single run."""

llm_calls: int
input_tokens: int
output_tokens: int

@classmethod
def from_stats(cls, stats: AssignmentStats | None) -> 'StepUsage | None':
if stats is None:
return None
return cls(
llm_calls=stats.total_calls,
input_tokens=stats.total_input_tokens,
output_tokens=stats.total_output_tokens,
)


@dataclass(frozen=True)
class PipelineStats:
"""Per-step LLM usage for a single run. A step is None when it did not run (e.g. cached assignments)."""

assignment: StepUsage | None
generation: StepUsage | None
polishing: StepUsage | None
elapsed_seconds: float


@dataclass
class PipelineMetricsCollector:
model_name: str
cost_per_mil_input: float
cost_per_mil_output: float
steps: list[PipelineMetricStep] = field(default_factory=list)

def add_step(self, step_name: str, stats: AssignmentStats | None) -> None:
if stats is None:
return
self.steps.append(PipelineMetricStep(name=step_name, stats=stats))

def get_stats(self, elapsed_seconds: float) -> JsonValue:
return self._build_summary_section(elapsed_seconds)

def log_summary(self, logger: logging.Logger) -> None:
if not self.steps:
logger.debug('No pipeline metrics were collected.')
return

logger.debug('Token usage and cost:')
logger.debug('Token usage:')
for step in self.steps:
_, _, total_cost = self._price(step.stats)
logger.debug(
'%s: %s calls, %s in / %s out tokens, %.2f USD',
'%s: %s calls, %s in / %s out tokens',
step.name,
f'{step.stats.total_calls:,}',
f'{step.stats.total_input_tokens:,}',
f'{step.stats.total_output_tokens:,}',
total_cost,
)

logger.debug(
'Total: %s in / %s out tokens, %.2f USD',
'Total: %s calls, %s in / %s out tokens',
f'{self.total_llm_calls:,}',
f'{self.total_input_tokens:,}',
f'{self.total_output_tokens:,}',
self.total_cost,
)

@property
def total_llm_calls(self) -> int:
return sum(step.stats.total_calls for step in self.steps)

@property
def total_input_tokens(self) -> int:
return sum(step.stats.total_input_tokens for step in self.steps)
Expand All @@ -59,58 +83,6 @@ def total_input_tokens(self) -> int:
def total_output_tokens(self) -> int:
return sum(step.stats.total_output_tokens for step in self.steps)

@property
def total_cost(self) -> float:
return sum(self._price(step.stats)[2] for step in self.steps)

@property
def total_llm_wait_ms(self) -> float:
return round(sum(step.stats.total_llm_wait_ms for step in self.steps), 3)

@property
def total_llm_response_ms(self) -> float:
return round(sum(step.stats.total_llm_response_ms for step in self.steps), 3)

def _price(self, stats: AssignmentStats) -> tuple[float, float, float]:
input_cost = stats.total_input_tokens * self.cost_per_mil_input / 1_000_000
output_cost = stats.total_output_tokens * self.cost_per_mil_output / 1_000_000
return input_cost, output_cost, input_cost + output_cost

def _build_summary_section(self, elapsed_seconds: float) -> JsonValue:
return {
'title': 'Pipeline token usage and cost',
'headers': [
'Step',
'LLM calls',
'Input tokens',
'Output tokens',
'Cost (USD)',
],
'rows': [
{
'step': step.name,
'llm_calls': step.stats.total_calls,
'input_tokens': step.stats.total_input_tokens,
'output_tokens': step.stats.total_output_tokens,
'cost_usd': round(self._price(step.stats)[2], 2),
}
for step in self.steps
],
'totals': {
'input_tokens': self.total_input_tokens,
'output_tokens': self.total_output_tokens,
'cost_usd': round(self.total_cost, 2),
},
'meta': {
'model_name': self.model_name,
'cost_per_mil_input': self.cost_per_mil_input,
'cost_per_mil_output': self.cost_per_mil_output,
'elapsed_seconds': elapsed_seconds,
'total_llm_wait_ms': self.total_llm_wait_ms,
'total_llm_response_ms': self.total_llm_response_ms,
},
}


def _get_component_dict(
pipeline_result: Mapping[str, object],
Expand Down
Loading
Loading