Skip to content

Commit cd6a234

Browse files
Improve cost Lambdas: invoke_model, NOK amounts, context block narrative
cost_report: - Switch LLM from converse() to invoke_model() (Anthropic Messages API) for boto3 compatibility - Add NOK amounts alongside USD (approximate rate 10.5) - Move LLM narrative to context block at bottom - Add Cost Explorer link to context footer - Per-project breakdown shows NOK with percentage in code blocks daily_cost_check: - Add NOK amounts to spike alerts - Add NOK rate note to context footer
1 parent 3ced188 commit cd6a234

2 files changed

Lines changed: 95 additions & 96 deletions

File tree

‎terraform/lambda-src/cost_report/handler.py‎

Lines changed: 89 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def cost_explorer_url(start, end):
149149

150150
MAX_SERVICES_PER_CATEGORY = 8
151151
SPIKE_THRESHOLD_PCT = 15.0
152+
USD_TO_NOK = 10.5 # Approximate exchange rate
152153

153154

154155
# ---------------------------------------------------------------------------
@@ -158,6 +159,12 @@ def fmt_date(d):
158159
return d.strftime("%b %d")
159160

160161

162+
def fmt_usd_nok(usd):
163+
"""Format amount in both USD and NOK."""
164+
nok = usd * USD_TO_NOK
165+
return f"${usd:.2f} (~{nok:.0f} NOK)"
166+
167+
161168
def fmt_service_lines(services, start, end, week_total):
162169
"""Return sorted mrkdwn lines with % of total."""
163170
url = cost_explorer_url(start, end)
@@ -193,81 +200,76 @@ def fmt_change(current, previous):
193200
# LLM narrative generation via Bedrock — structured output
194201
# ---------------------------------------------------------------------------
195202
_NARRATIVE_TOOL = {
196-
"tools": [{
197-
"toolSpec": {
198-
"name": "submit_narrative",
199-
"description": "Submit a cost report narrative summary",
200-
"inputSchema": {
201-
"json": {
202-
"type": "object",
203-
"properties": {
204-
"summary": {
205-
"type": "string",
206-
"description": "2-3 sentence executive summary of the cost report",
207-
},
208-
"notable": {
209-
"type": "boolean",
210-
"description": "True if there is something worth calling out "
211-
"(significant change, unusual pattern). False if costs are stable.",
212-
},
213-
},
214-
"required": ["summary", "notable"],
215-
}
203+
"name": "submit_narrative",
204+
"description": "Submit a cost report narrative summary",
205+
"input_schema": {
206+
"type": "object",
207+
"properties": {
208+
"summary": {
209+
"type": "string",
210+
"description": "2-3 sentence executive summary of the cost report",
216211
},
217-
}
218-
}],
219-
"toolChoice": {"any": {}},
212+
"notable": {
213+
"type": "boolean",
214+
"description": "True if there is something worth calling out "
215+
"(significant change, unusual pattern). False if costs are stable.",
216+
},
217+
},
218+
"required": ["summary", "notable"],
219+
},
220220
}
221221

222222
_SPIKE_TOOL = {
223-
"tools": [{
224-
"toolSpec": {
225-
"name": "submit_spike_analysis",
226-
"description": "Submit a root cause analysis for a cost spike",
227-
"inputSchema": {
228-
"json": {
229-
"type": "object",
230-
"properties": {
231-
"root_cause": {
232-
"type": "string",
233-
"description": "2-3 sentence explanation of the likely root cause",
234-
},
235-
"suggested_action": {
236-
"type": "string",
237-
"description": "One concrete action to investigate",
238-
},
239-
"severity": {
240-
"type": "string",
241-
"enum": ["LOW", "MEDIUM", "HIGH"],
242-
"description": "LOW = expected growth, MEDIUM = worth investigating, "
243-
"HIGH = likely anomaly needing immediate attention",
244-
},
245-
},
246-
"required": ["root_cause", "suggested_action", "severity"],
247-
}
223+
"name": "submit_spike_analysis",
224+
"description": "Submit a root cause analysis for a cost spike",
225+
"input_schema": {
226+
"type": "object",
227+
"properties": {
228+
"root_cause": {
229+
"type": "string",
230+
"description": "2-3 sentence explanation of the likely root cause",
231+
},
232+
"suggested_action": {
233+
"type": "string",
234+
"description": "One concrete action to investigate",
248235
},
249-
}
250-
}],
251-
"toolChoice": {"any": {}},
236+
"severity": {
237+
"type": "string",
238+
"enum": ["LOW", "MEDIUM", "HIGH"],
239+
"description": "LOW = expected growth, MEDIUM = worth investigating, "
240+
"HIGH = likely anomaly needing immediate attention",
241+
},
242+
},
243+
"required": ["root_cause", "suggested_action", "severity"],
244+
},
252245
}
253246

254247

255-
def _llm_structured(prompt, tool_config):
256-
"""Call Bedrock with tool use, return structured dict or None."""
248+
def _llm_structured(prompt, tool):
249+
"""Call Bedrock invoke_model with tool use, return structured dict or None."""
257250
try:
258251
bedrock = boto3.client(
259252
"bedrock-runtime", region_name=LLM_REGION,
260253
config=Config(read_timeout=20, connect_timeout=5),
261254
)
262-
response = bedrock.converse(
255+
request_body = json.dumps({
256+
"anthropic_version": "bedrock-2023-05-31",
257+
"max_tokens": 1024,
258+
"temperature": 0.2,
259+
"messages": [{"role": "user", "content": prompt}],
260+
"tools": [tool],
261+
"tool_choice": {"type": "any"},
262+
})
263+
response = bedrock.invoke_model(
263264
modelId=LLM_MODEL_ID,
264-
messages=[{"role": "user", "content": [{"text": prompt}]}],
265-
toolConfig=tool_config,
266-
inferenceConfig={"temperature": 0.2},
265+
contentType="application/json",
266+
accept="application/json",
267+
body=request_body,
267268
)
268-
for content in response["output"]["message"]["content"]:
269-
if "toolUse" in content:
270-
return content["toolUse"]["input"]
269+
response_body = json.loads(response["body"].read())
270+
for block in response_body.get("content", []):
271+
if block.get("type") == "tool_use" and block.get("name") == tool["name"]:
272+
return block["input"]
271273
except Exception as e:
272274
logger.warning("LLM call failed: %s", e)
273275
return None
@@ -370,7 +372,8 @@ def build_blocks(this_week, prev_week, mtd, prev_mtd, project_costs,
370372
for proj_name, cost in sorted_projects:
371373
if cost >= 0.01:
372374
pct = (cost / tw_total * 100) if tw_total > 0 else 0
373-
project_lines.append(f" * {proj_name} — *${cost:.2f}* ({pct:.1f}%)")
375+
nok = cost * USD_TO_NOK
376+
project_lines.append(f" \u2022 {proj_name} \u2014 *${cost:.2f}* (~{nok:.0f} NOK) `{pct:.1f}%`")
374377
if project_lines:
375378
blocks.append({
376379
"type": "section",
@@ -409,12 +412,12 @@ def build_blocks(this_week, prev_week, mtd, prev_mtd, project_costs,
409412
blocks.append({
410413
"type": "section",
411414
"fields": [
412-
{"type": "mrkdwn", "text": f"*Total This Week*\n${tw_total:.2f}"},
413-
{"type": "mrkdwn", "text": f"*Previous Week*\n${pw_total:.2f}"},
415+
{"type": "mrkdwn", "text": f"*Total This Week*\n{fmt_usd_nok(tw_total)}"},
416+
{"type": "mrkdwn", "text": f"*Previous Week*\n{fmt_usd_nok(pw_total)}"},
414417
{"type": "mrkdwn", "text": f"*Week-over-Week*\n{fmt_change(tw_total, pw_total)}"},
415-
{"type": "mrkdwn", "text": f"*Avg Daily*\n${daily_avg:.2f}"},
416-
{"type": "mrkdwn", "text": f"*Month-to-Date*\n${mtd_total:.2f}"},
417-
{"type": "mrkdwn", "text": f"*Projected {curr_month_name}*\n${projected:.2f}"},
418+
{"type": "mrkdwn", "text": f"*Avg Daily*\n{fmt_usd_nok(daily_avg)}"},
419+
{"type": "mrkdwn", "text": f"*Month-to-Date*\n{fmt_usd_nok(mtd_total)}"},
420+
{"type": "mrkdwn", "text": f"*Projected {curr_month_name}*\n{fmt_usd_nok(projected)}"},
418421
]
419422
})
420423

@@ -429,28 +432,16 @@ def build_blocks(this_week, prev_week, mtd, prev_mtd, project_costs,
429432
blocks.append({
430433
"type": "section",
431434
"fields": [
432-
{"type": "mrkdwn", "text": f"*MTD Total*\n${mtd_total:.2f}"},
433-
{"type": "mrkdwn", "text": f"*{prev_month_name} Same Period*\n${prev_mtd_total:.2f}"},
435+
{"type": "mrkdwn", "text": f"*MTD Total*\n{fmt_usd_nok(mtd_total)}"},
436+
{"type": "mrkdwn", "text": f"*{prev_month_name} Same Period*\n{fmt_usd_nok(prev_mtd_total)}"},
434437
{"type": "mrkdwn", "text": f"*MTD Change*\n{fmt_change(mtd_total, prev_mtd_total)}"},
435-
{"type": "mrkdwn", "text": f"*Projected {curr_month_name}*\n${projected:.2f}"},
438+
{"type": "mrkdwn", "text": f"*Projected {curr_month_name}*\n{fmt_usd_nok(projected)}"},
436439
]
437440
})
438441

439442
blocks.append({"type": "divider"})
440443

441-
# LLM narrative
442-
narrative_result = generate_narrative(
443-
this_week, prev_week, mtd,
444-
tw_total, pw_total, mtd_total, projected, curr_month_name,
445-
)
446-
if narrative_result:
447-
blocks.append({
448-
"type": "section",
449-
"text": {"type": "mrkdwn",
450-
"text": f":brain: *Summary*\n{narrative_result.get('summary', '')}"}
451-
})
452-
453-
# LLM spike root cause
444+
# LLM spike root cause (shown as a section — important)
454445
spike_result = analyze_spike(this_week, prev_week, tw_total, pw_total)
455446
if spike_result:
456447
sev_emoji = {"HIGH": ":red_circle:", "MEDIUM": ":large_orange_circle:"}.get(
@@ -461,18 +452,25 @@ def build_blocks(this_week, prev_week, mtd, prev_mtd, project_costs,
461452
"text": f":warning: *Spike Analysis* {sev_emoji}\n{spike_result.get('root_cause', '')}\n\n"
462453
f"_Suggested action: {spike_result.get('suggested_action', 'N/A')}_"}
463454
})
464-
465-
if narrative_result or spike_result:
466455
blocks.append({"type": "divider"})
467456

457+
# LLM narrative + footer as context blocks
458+
narrative_result = generate_narrative(
459+
this_week, prev_week, mtd,
460+
tw_total, pw_total, mtd_total, projected, curr_month_name,
461+
)
462+
463+
ce_url = cost_explorer_url(tw_start, tw_end)
468464
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
469-
blocks.append({
470-
"type": "context",
471-
"elements": [
472-
{"type": "mrkdwn",
473-
"text": f"Source: AWS Cost Explorer | Generated {ts}"}
474-
]
475-
})
465+
context_parts = []
466+
if narrative_result:
467+
context_parts.append(
468+
{"type": "mrkdwn", "text": f":brain: {narrative_result.get('summary', '')}"}
469+
)
470+
context_parts.append(
471+
{"type": "mrkdwn", "text": f"<{ce_url}|View in Cost Explorer> | Generated {ts} | NOK rate: ~{USD_TO_NOK}"}
472+
)
473+
blocks.append({"type": "context", "elements": context_parts})
476474

477475
return blocks
478476

‎terraform/lambda-src/daily_cost_check/handler.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313

1414
COST_WEBHOOK_PARAM = os.environ["COST_WEBHOOK_PARAM"]
1515
SPIKE_THRESHOLD = float(os.environ.get("SPIKE_THRESHOLD", "1.2")) # 20% above average
16-
17-
# Javabin doesn't use Bedrock for AI workloads, so no exclusion needed.
16+
USD_TO_NOK = 10.5 # Approximate exchange rate
1817

1918
_webhook_cache = {}
2019

@@ -114,12 +113,14 @@ def build_alert_blocks(spikes, yesterday_date):
114113
]
115114

116115
for svc, yesterday, avg, pct in spikes:
116+
nok = yesterday * USD_TO_NOK
117117
fields = [
118118
{"type": "mrkdwn", "text": f"*Service*\n{svc}"},
119-
{"type": "mrkdwn", "text": f"*Yesterday*\n${yesterday:.2f}"},
119+
{"type": "mrkdwn", "text": f"*Yesterday*\n${yesterday:.2f} (~{nok:.0f} NOK)"},
120120
]
121121
if avg > 0:
122-
fields.append({"type": "mrkdwn", "text": f"*7-day avg*\n${avg:.2f}"})
122+
avg_nok = avg * USD_TO_NOK
123+
fields.append({"type": "mrkdwn", "text": f"*7-day avg*\n${avg:.2f} (~{avg_nok:.0f} NOK)"})
123124
fields.append({"type": "mrkdwn", "text": f"*Change*\n{fmt_change(pct, yesterday, avg)}"})
124125

125126
blocks.append({"type": "section", "fields": fields})
@@ -130,7 +131,7 @@ def build_alert_blocks(spikes, yesterday_date):
130131
blocks.append({
131132
"type": "context",
132133
"elements": [{"type": "mrkdwn",
133-
"text": f"Source: AWS Cost Explorer | Generated {ts}"}]
134+
"text": f"Source: AWS Cost Explorer | Generated {ts} | NOK rate: ~{USD_TO_NOK}"}]
134135
})
135136

136137
return blocks

0 commit comments

Comments
 (0)