Skip to content

Commit 75f363e

Browse files
committed
Merge branch 'feature/categorize-cost-estimate-by-step' into 'develop'
Feature/categorize cost estimate by step See merge request genaiic-reusable-assets/engagement-artifacts/genaiic-idp-accelerator!154
2 parents 8c6f2eb + d674f5a commit 75f363e

File tree

8 files changed

+127
-23
lines changed

8 files changed

+127
-23
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ SPDX-License-Identifier: MIT-0
77

88
### Added
99

10+
- **Cost Estimator UI Feature for Context Grouping and Subtotals**
11+
- Added context grouping functionality to organize cost estimates by logical categories (e.g. OCR, Classification, etc.)
12+
- Implemented subtotal calculations for better cost breakdown visualization
13+
1014
- **DynamoDB Caching for Resilient Classification**
1115
- Added optional DynamoDB caching to the multimodal page-level classification service to improve efficiency and resilience
1216
- Cache successful page classification results to avoid redundant processing during retries when some pages fail due to throttling

lib/idp_common_pkg/idp_common/bedrock/client.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ def __call__(
7878
top_k: Optional[Union[float, str]] = None,
7979
top_p: Optional[Union[float, str]] = None,
8080
max_tokens: Optional[Union[int, str]] = None,
81-
max_retries: Optional[int] = None
81+
max_retries: Optional[int] = None,
82+
context: str = "Unspecified"
8283
) -> Dict[str, Any]:
8384
"""
8485
Make the instance callable with the same signature as the original function.
@@ -109,7 +110,8 @@ def __call__(
109110
top_k=top_k,
110111
top_p=top_p,
111112
max_tokens=max_tokens,
112-
max_retries=effective_max_retries
113+
max_retries=effective_max_retries,
114+
context=context
113115
)
114116

115117
def _preprocess_content_for_cachepoint(self, content: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -177,7 +179,8 @@ def invoke_model(
177179
top_k: Optional[Union[float, str]] = 5,
178180
top_p: Optional[Union[float, str]] = 0.1,
179181
max_tokens: Optional[Union[int, str]] = None,
180-
max_retries: Optional[int] = None
182+
max_retries: Optional[int] = None,
183+
context: str = "Unspecified"
181184
) -> Dict[str, Any]:
182185
"""
183186
Invoke a Bedrock model with retry logic.
@@ -335,7 +338,8 @@ def invoke_model(
335338
converse_params=converse_params,
336339
retry_count=0,
337340
max_retries=effective_max_retries,
338-
request_start_time=request_start_time
341+
request_start_time=request_start_time,
342+
context=context
339343
)
340344

341345
return result
@@ -346,7 +350,8 @@ def _invoke_with_retry(
346350
retry_count: int,
347351
max_retries: int,
348352
request_start_time: float,
349-
last_exception: Exception = None
353+
last_exception: Exception = None,
354+
context: str = "Unspecified"
350355
) -> Dict[str, Any]:
351356
"""
352357
Recursive helper method to handle retries for Bedrock invocation.
@@ -424,7 +429,7 @@ def _invoke_with_retry(
424429
response_with_metering = {
425430
"response": response,
426431
"metering": {
427-
f"bedrock/{converse_params['modelId']}": {
432+
f"{context}/bedrock/{converse_params['modelId']}": {
428433
**usage
429434
}
430435
}
@@ -470,7 +475,8 @@ def _invoke_with_retry(
470475
retry_count=retry_count + 1,
471476
max_retries=max_retries,
472477
request_start_time=request_start_time,
473-
last_exception=e
478+
last_exception=e,
479+
context=context
474480
)
475481
else:
476482
logger.error(f"Non-retryable Bedrock error: {error_code} - {error_message}")
@@ -838,6 +844,7 @@ def _sanitize_response_for_logging(self, response: Dict[str, Any]) -> Dict[str,
838844
top_p: Optional top_p parameter (float or string)
839845
max_tokens: Optional max_tokens parameter (int or string)
840846
max_retries: Optional override for the instance's max_retries setting
847+
context: Context prefix for metering key (default: "Unspecified")
841848
842849
Returns:
843850
Bedrock response object with metering information

lib/idp_common_pkg/idp_common/classification/service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ def classify_page_sagemaker(
770770

771771
# Add some metering data for consistency with Bedrock
772772
metering = {
773-
"sagemaker/invoke_endpoint": {
773+
"Classification/sagemaker/invoke_endpoint": {
774774
"invocations": 1,
775775
}
776776
}
@@ -910,6 +910,7 @@ def _invoke_bedrock_model(
910910
top_k=config["top_k"],
911911
top_p=config["top_p"],
912912
max_tokens=config["max_tokens"],
913+
context="Classification",
913914
)
914915

915916
def _create_unclassified_result(

lib/idp_common_pkg/idp_common/extraction/service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,7 @@ def process_document_section(self, document: Document, section_id: str) -> Docum
708708
top_k=top_k,
709709
top_p=top_p,
710710
max_tokens=max_tokens,
711+
context="Extraction",
711712
)
712713

713714
total_duration = time.time() - request_start_time

lib/idp_common_pkg/idp_common/ocr/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def _process_single_page(
284284
# Extract metering data
285285
feature_combo = self._feature_combo()
286286
metering = {
287-
f"textract/{self._get_api_name()}{feature_combo}": {
287+
f"OCR/textract/{self._get_api_name()}{feature_combo}": {
288288
"pages": textract_result["DocumentMetadata"]["Pages"]
289289
}
290290
}

lib/idp_common_pkg/idp_common/summarization/service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ def _invoke_bedrock_model(
221221
top_k=config["top_k"],
222222
top_p=config["top_p"],
223223
max_tokens=config["max_tokens"],
224+
context="Summarization",
224225
)
225226

226227
def _create_error_summary(self, error_message: str) -> DocumentSummary:

patterns/pattern-1/src/processresults_function/index.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,10 +549,10 @@ def handler(event, context):
549549

550550
# Add metering information
551551
document.metering = {
552-
"bda/documents-custom": {
552+
"BDAProject/bda/documents-custom": {
553553
"pages": custom_pages_count
554554
},
555-
"bda/documents-standard": {
555+
"BDAProject/bda/documents-standard": {
556556
"pages": standard_pages_count
557557
}
558558
}

src/ui/src/components/document-panel/DocumentPanel.jsx

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,27 @@ import useConfiguration from '../../hooks/use-configuration';
2222

2323
const logger = new Logger('DocumentPanel');
2424

25-
// Format the cost cell content based on whether it's a total row
26-
const formatCostCell = (item) => {
27-
if (item.isTotal) {
28-
return <Box fontWeight="bold">{`${item.note}: ${item.cost}`}</Box>;
25+
// Helper function to parse serviceApi key into context and service
26+
const parseServiceApiKey = (serviceApiKey) => {
27+
const parts = serviceApiKey.split('/');
28+
if (parts.length >= 3) {
29+
const context = parts[0];
30+
const serviceApi = parts.slice(1).join('/');
31+
return { context, serviceApi };
2932
}
30-
return item.cost;
33+
// Fallback for keys that don't follow the new format (less than 3 parts) - set context to ''
34+
return { context: '', serviceApi: serviceApiKey };
35+
};
36+
37+
// Helper function to format cost cells
38+
const formatCostCell = (rowItem) => {
39+
if (rowItem.isTotal) {
40+
return <Box fontWeight="bold">{`${rowItem.note}: ${rowItem.cost}`}</Box>;
41+
}
42+
if (rowItem.isSubtotal) {
43+
return <Box fontWeight="bold" color="text-body-secondary">{`${rowItem.note}: ${rowItem.cost}`}</Box>;
44+
}
45+
return rowItem.cost;
3146
};
3247

3348
// Component to display metering information in a table
@@ -65,15 +80,18 @@ const MeteringTable = ({ meteringData, preCalculatedTotals }) => {
6580
return <Box>Loading pricing data...</Box>;
6681
}
6782

68-
// Transform metering data into table rows
69-
const tableItems = [];
83+
// Transform metering data into table rows with context parsing
84+
const rawTableItems = [];
85+
const contextTotals = {};
7086
let totalCost = 0;
7187

72-
Object.entries(meteringData).forEach(([serviceApi, metrics]) => {
88+
Object.entries(meteringData).forEach(([originalServiceApiKey, metrics]) => {
89+
const { context, serviceApi } = parseServiceApiKey(originalServiceApiKey);
90+
7391
Object.entries(metrics).forEach(([unit, value]) => {
7492
const numericValue = Number(value);
7593

76-
// Look up the unit price from the pricing data
94+
// Look up the unit price from the pricing data using the parsed serviceApi
7795
let unitPrice = null;
7896
let unitPriceDisplayValue = 'None';
7997
let cost = 0;
@@ -83,6 +101,13 @@ const MeteringTable = ({ meteringData, preCalculatedTotals }) => {
83101
unitPriceDisplayValue = `$${unitPrice}`;
84102
cost = numericValue * unitPrice;
85103
totalCost += cost;
104+
105+
// Track context totals
106+
if (!contextTotals[context]) {
107+
contextTotals[context] = 0;
108+
}
109+
contextTotals[context] += cost;
110+
86111
logger.debug(`Found price for ${serviceApi}/${unit}: ${unitPriceDisplayValue}`);
87112
} else {
88113
logger.warn(`Invalid price for ${serviceApi}/${unit}, using None`);
@@ -91,34 +116,96 @@ const MeteringTable = ({ meteringData, preCalculatedTotals }) => {
91116
logger.debug(`No price found for ${serviceApi}/${unit}, using None`);
92117
}
93118

94-
tableItems.push({
119+
rawTableItems.push({
120+
context,
95121
serviceApi,
96122
unit,
97123
value: String(numericValue),
98124
unitCost: unitPriceDisplayValue,
99125
cost: unitPrice !== null ? `$${cost.toFixed(4)}` : 'N/A',
126+
costValue: cost,
100127
isTotal: false,
128+
isSubtotal: false,
101129
});
102130
});
103131
});
104132

133+
// Group items by context and add subtotals
134+
const tableItems = [];
135+
const contextGroups = {};
136+
137+
// Group raw items by context
138+
rawTableItems.forEach((item) => {
139+
if (!contextGroups[item.context]) {
140+
contextGroups[item.context] = [];
141+
}
142+
contextGroups[item.context].push(item);
143+
});
144+
145+
// Sort contexts in specific order: OCR, Classification, Extraction, Summarization
146+
const contextOrder = ['BDAProject', 'OCR', 'Classification', 'Extraction', 'Summarization'];
147+
const sortedContexts = Object.keys(contextGroups).sort((a, b) => {
148+
const aIndex = contextOrder.indexOf(a);
149+
const bIndex = contextOrder.indexOf(b);
150+
151+
// If both contexts are in the predefined order, sort by their position
152+
if (aIndex !== -1 && bIndex !== -1) {
153+
return aIndex - bIndex;
154+
}
155+
156+
// If only one context is in the predefined order, it comes first
157+
if (aIndex !== -1) return -1;
158+
if (bIndex !== -1) return 1;
159+
160+
// If neither context is in the predefined order, sort alphabetically
161+
return a.localeCompare(b);
162+
});
163+
164+
sortedContexts.forEach((context) => {
165+
// Add all items for this context
166+
tableItems.push(...contextGroups[context]);
167+
168+
// Add subtotal row for this context
169+
const contextTotal = contextTotals[context] || 0;
170+
tableItems.push({
171+
context: '',
172+
serviceApi: '',
173+
unit: '',
174+
value: '',
175+
unitCost: '',
176+
cost: `$${contextTotal.toFixed(4)}`,
177+
costValue: contextTotal,
178+
isTotal: false,
179+
isSubtotal: true,
180+
note: `${context} Subtotal`,
181+
});
182+
});
183+
105184
// Use preCalculatedTotals if provided, otherwise calculate locally
106185
const finalTotalCost = preCalculatedTotals ? preCalculatedTotals.totalCost : totalCost;
107186

108-
// Add total row
187+
// Add overall total row
109188
tableItems.push({
189+
context: '',
110190
serviceApi: '',
111191
unit: '',
112192
value: '',
113193
unitCost: '',
114194
cost: `$${finalTotalCost.toFixed(4)}`,
195+
costValue: finalTotalCost,
115196
isTotal: true,
197+
isSubtotal: false,
116198
note: 'Total',
117199
});
118200

119201
return (
120202
<Table
121203
columnDefinitions={[
204+
{
205+
id: 'context',
206+
header: 'Context',
207+
cell: (rowItem) => rowItem.context,
208+
},
122209
{
123210
id: 'serviceApi',
124211
header: 'Service/Api',
@@ -169,7 +256,10 @@ const calculateTotalCosts = (meteringData, documentItem, pricingData) => {
169256
let totalCost = 0;
170257

171258
if (pricingData) {
172-
Object.entries(meteringData).forEach(([serviceApi, metrics]) => {
259+
Object.entries(meteringData).forEach(([originalServiceApiKey, metrics]) => {
260+
// Parse the serviceApi key to remove context prefix
261+
const { serviceApi } = parseServiceApiKey(originalServiceApiKey);
262+
173263
Object.entries(metrics).forEach(([unit, value]) => {
174264
const numericValue = Number(value);
175265
if (pricingData[serviceApi] && pricingData[serviceApi][unit] !== undefined) {

0 commit comments

Comments
 (0)