Expand AI insight: fix broken competitor query, add continuity, 30-day horizon, holidays
- Fix gather_competitor_data() tier filter: it queried tier IN ('primary','secondary'),
values that never exist (real values are 'own'/'competitor'/'market'), so the
cheapest-competitor comparison has always silently returned nothing.
- Feed the previous insight back into the prompt so the model can note what's
changed/resolved instead of repeating itself.
- Extend forecast horizon from 14 to 30 days; add a per-day revenue table
alongside the existing occupancy table.
- Annotate the occupancy table with UK (England) bank holidays.
- Add a same-channel market-movement section (B.com vs B.com, rack vs rack)
diffing rates against the last insight's snapshot, threshold £3.
- Add a parsed headline field + insight history list on the Dashboard,
collapsed to headline/age and expandable to full content.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2f5349bd1c
commit
2a7ee1d6b8
9 changed files with 381 additions and 83 deletions
|
|
@ -1,18 +1,18 @@
|
|||
"""
|
||||
AI Daily Insights Generation Job
|
||||
|
||||
Gathers Pickup-V2 forecast data, booking pace, competitor rates, and rate parity
|
||||
information, then sends a compact prompt to Anthropic's Haiku model to generate
|
||||
a daily briefing for hotel revenue staff.
|
||||
Gathers Pickup-V2 forecast data, booking pace, competitor rates, rate parity,
|
||||
UK bank holidays, and the previous insight, then sends a compact prompt to
|
||||
Anthropic's Haiku model to generate a daily briefing for hotel revenue staff.
|
||||
|
||||
Schedule: Daily at 7:15 AM (after all forecasts and accuracy calc complete)
|
||||
Cost: ~$0.05/month at 1 run/day with Haiku
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, timedelta, datetime, timezone
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
import holidays
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -21,8 +21,10 @@ from database import AsyncSessionLocal
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
||||
DEFAULT_DAILY_TOKEN_BUDGET = 5000
|
||||
MAX_OUTPUT_TOKENS = 400
|
||||
DEFAULT_DAILY_TOKEN_BUDGET = 12000
|
||||
MAX_OUTPUT_TOKENS = 550
|
||||
FORECAST_HORIZON_DAYS = 30
|
||||
RATE_MOVEMENT_THRESHOLD = 3.0 # GBP — minimum delta to surface as a market move
|
||||
|
||||
|
||||
async def get_config(db: AsyncSession) -> Dict[str, str]:
|
||||
|
|
@ -61,7 +63,28 @@ async def check_daily_budget(db: AsyncSession, budget: int) -> tuple[bool, int]:
|
|||
return used < budget, used
|
||||
|
||||
|
||||
async def gather_occupancy_data(db: AsyncSession, days: int = 14) -> List[Dict]:
|
||||
async def get_previous_insight(db: AsyncSession) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch the most recent insight so the new one can compare against it."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT headline, content, generated_at, data_snapshot
|
||||
FROM ai_insights
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"headline": row.headline,
|
||||
"content": row.content,
|
||||
"generated_at": row.generated_at,
|
||||
"data_snapshot": row.data_snapshot or {},
|
||||
}
|
||||
|
||||
|
||||
async def gather_occupancy_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> List[Dict]:
|
||||
"""Gather Pickup-V2 occupancy forecast data for the next N days."""
|
||||
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast
|
||||
|
||||
|
|
@ -78,7 +101,7 @@ async def gather_occupancy_data(db: AsyncSession, days: int = 14) -> List[Dict]:
|
|||
return []
|
||||
|
||||
|
||||
async def gather_revenue_data(db: AsyncSession, days: int = 14) -> List[Dict]:
|
||||
async def gather_revenue_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> List[Dict]:
|
||||
"""Gather Pickup-V2 revenue forecast data for the next N days."""
|
||||
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast
|
||||
|
||||
|
|
@ -95,7 +118,7 @@ async def gather_revenue_data(db: AsyncSession, days: int = 14) -> List[Dict]:
|
|||
return []
|
||||
|
||||
|
||||
async def gather_budget_data(db: AsyncSession, days: int = 14) -> Dict[str, float]:
|
||||
async def gather_budget_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> Dict[str, float]:
|
||||
"""Gather budget values for forecast comparison."""
|
||||
today = date.today()
|
||||
end = today + timedelta(days=days - 1)
|
||||
|
|
@ -117,7 +140,7 @@ async def gather_budget_data(db: AsyncSession, days: int = 14) -> Dict[str, floa
|
|||
return budgets
|
||||
|
||||
|
||||
async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str, Any]:
|
||||
async def gather_competitor_data(db: AsyncSession, days: int = FORECAST_HORIZON_DAYS) -> Dict[str, Any]:
|
||||
"""Gather competitor rate data from Booking.com scraper."""
|
||||
today = date.today()
|
||||
end = today + timedelta(days=days - 1)
|
||||
|
|
@ -151,7 +174,7 @@ async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str,
|
|||
h.name as comp_name
|
||||
FROM booking_com_rates r
|
||||
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||
WHERE h.tier IN ('primary', 'secondary')
|
||||
WHERE h.tier IN ('competitor', 'market')
|
||||
AND r.rate_date BETWEEN :start AND :end
|
||||
AND r.availability_status = 'available'
|
||||
AND r.rate_gross IS NOT NULL
|
||||
|
|
@ -186,6 +209,21 @@ async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str,
|
|||
}
|
||||
|
||||
|
||||
def get_uk_bank_holidays(start: date, end: date) -> Dict[str, str]:
|
||||
"""UK (England) bank holidays within the given range, keyed by YYYY-MM-DD."""
|
||||
years = list(range(start.year, end.year + 1))
|
||||
try:
|
||||
uk_holidays = holidays.UK(subdiv='England', years=years)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load UK bank holidays: {e}")
|
||||
return {}
|
||||
|
||||
return {
|
||||
str(d): name for d, name in uk_holidays.items()
|
||||
if start <= d <= end
|
||||
}
|
||||
|
||||
|
||||
def _fmt_date(d: str) -> str:
|
||||
"""Convert YYYY-MM-DD to DD/MM/YYYY for UK display."""
|
||||
try:
|
||||
|
|
@ -197,28 +235,116 @@ def _fmt_date(d: str) -> str:
|
|||
return str(d)
|
||||
|
||||
|
||||
def build_market_movement_section(
|
||||
current: Dict[str, Any],
|
||||
previous: Optional[Dict[str, Any]],
|
||||
threshold: float = RATE_MOVEMENT_THRESHOLD
|
||||
) -> List[str]:
|
||||
"""
|
||||
Diff competitor/own rates against the previous insight's snapshot.
|
||||
Only same-channel deltas are compared (B.com vs B.com, Rack vs Rack) so
|
||||
a direct rack rate is never held up against a competitor's B.com rate.
|
||||
"""
|
||||
lines = ["## Market Movement Since Last Insight"]
|
||||
|
||||
if not previous:
|
||||
lines.append("No previous insight to compare against (first run).")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
prev_competitors = previous.get('competitors', {}) or {}
|
||||
prev_own_booking = previous.get('own_booking', {}) or {}
|
||||
prev_rack = previous.get('rack', {}) or {}
|
||||
|
||||
cur_competitors = current.get('competitors', {}) or {}
|
||||
cur_own_booking = current.get('own_booking', {}) or {}
|
||||
cur_rack = current.get('rack', {}) or {}
|
||||
|
||||
moves = []
|
||||
|
||||
for d, cur in cur_competitors.items():
|
||||
prev = prev_competitors.get(d)
|
||||
if not prev or not cur.get('rate') or not prev.get('rate'):
|
||||
continue
|
||||
delta = cur['rate'] - prev['rate']
|
||||
if abs(delta) >= threshold:
|
||||
moves.append(
|
||||
f"{_fmt_date(d)} | Cheapest competitor ({cur.get('name', '?')}, B.com): "
|
||||
f"£{prev['rate']:.0f} -> £{cur['rate']:.0f} ({delta:+.0f})"
|
||||
)
|
||||
|
||||
for d, cur in cur_own_booking.items():
|
||||
prev = prev_own_booking.get(d)
|
||||
if not prev or not cur.get('rate') or not prev.get('rate'):
|
||||
continue
|
||||
delta = cur['rate'] - prev['rate']
|
||||
if abs(delta) >= threshold:
|
||||
moves.append(
|
||||
f"{_fmt_date(d)} | Own rate (B.com): "
|
||||
f"£{prev['rate']:.0f} -> £{cur['rate']:.0f} ({delta:+.0f})"
|
||||
)
|
||||
|
||||
for d, cur_rate in cur_rack.items():
|
||||
prev_rate = prev_rack.get(d)
|
||||
if not prev_rate or not cur_rate:
|
||||
continue
|
||||
delta = cur_rate - prev_rate
|
||||
if abs(delta) >= threshold:
|
||||
moves.append(
|
||||
f"{_fmt_date(d)} | Own rate (Rack/Newbook): "
|
||||
f"£{prev_rate:.0f} -> £{cur_rate:.0f} ({delta:+.0f})"
|
||||
)
|
||||
|
||||
if moves:
|
||||
moves.sort()
|
||||
lines.extend(moves)
|
||||
else:
|
||||
lines.append(f"No competitor or own-rate movements of £{threshold:.0f}+ since last insight.")
|
||||
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def build_prompt(
|
||||
occupancy: List[Dict],
|
||||
revenue: List[Dict],
|
||||
budgets: Dict[str, float],
|
||||
competitor: Dict[str, Any]
|
||||
competitor: Dict[str, Any],
|
||||
bank_holidays: Dict[str, str],
|
||||
previous_insight: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Build system and user prompts from gathered data. Returns (system_msg, user_msg)."""
|
||||
|
||||
system_msg = (
|
||||
"You are an AI assistant for a hotel revenue manager in the UK. Analyze the data below and provide "
|
||||
"a concise daily briefing (3-5 bullet points). Focus on: occupancy trends, pace vs prior "
|
||||
"year, pricing opportunities, and anything unusual requiring attention. "
|
||||
"Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. "
|
||||
"Be specific with numbers and dates. Keep it actionable — no fluff or generic advice."
|
||||
"a daily briefing. Use UK date format (DD/MM/YYYY) and GBP (£) for all monetary values. "
|
||||
"Be specific with numbers and dates. Keep it actionable — no fluff or generic advice.\n\n"
|
||||
"Output format: first line must be `HEADLINE: <one sentence, the single most important takeaway>`, "
|
||||
"then a blank line, then 4-6 bullet points covering: occupancy/pace, pricing opportunities, "
|
||||
"notable competitor rate movements (only if the Market Movement section has any), UK bank holidays "
|
||||
"in the window if they affect pace, and anything unusual requiring attention.\n\n"
|
||||
"A 'Previous Insight' section may be included below — compare against it explicitly: call out what's "
|
||||
"changed, what's resolved, and what's still an open issue. Don't just repeat it verbatim.\n\n"
|
||||
"Never compare a direct/rack rate to a competitor's Booking.com rate as if they were the same channel — "
|
||||
"only compare rates within the same channel (B.com vs B.com, rack vs rack) when discussing parity or "
|
||||
"pricing moves."
|
||||
)
|
||||
|
||||
lines = []
|
||||
|
||||
if previous_insight:
|
||||
gen_at = previous_insight.get('generated_at')
|
||||
gen_at_str = gen_at.strftime('%d/%m/%Y %H:%M') if gen_at else 'unknown time'
|
||||
lines.append(f"## Previous Insight ({gen_at_str})")
|
||||
if previous_insight.get('headline'):
|
||||
lines.append(f"Headline: {previous_insight['headline']}")
|
||||
lines.append(previous_insight.get('content', ''))
|
||||
lines.append("")
|
||||
|
||||
# Occupancy section
|
||||
if occupancy:
|
||||
lines.append("## Occupancy Forecast - Pickup-V2 (next 14 days)")
|
||||
lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget")
|
||||
lines.append(f"## Occupancy Forecast - Pickup-V2 (next {FORECAST_HORIZON_DAYS} days)")
|
||||
lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget | Notes")
|
||||
for fc in occupancy:
|
||||
d = fc.get('date', '')
|
||||
dow = fc.get('day_of_week', '')
|
||||
|
|
@ -234,20 +360,50 @@ def build_prompt(
|
|||
py_str = f"{py_final:.0f}%" if py_final is not None else "-"
|
||||
pace_str = f"{pace:+.0f}%" if pace is not None else "-"
|
||||
bud_str = f"{budget_val:.0f}%" if budget_val is not None else "-"
|
||||
holiday_note = bank_holidays.get(str(d), "")
|
||||
|
||||
lines.append(f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str}")
|
||||
lines.append(
|
||||
f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str} | {holiday_note}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Revenue summary
|
||||
# Revenue section — per-day + aggregate
|
||||
if revenue:
|
||||
lines.append(f"## Revenue Forecast - Pickup-V2 (next {FORECAST_HORIZON_DAYS} days)")
|
||||
lines.append("Date | DoW | OTB Rev | Forecast Rev | PY Rev | Pace vs LY | Budget Rev | Opportunity")
|
||||
for fc in revenue:
|
||||
d = fc.get('date', '')
|
||||
dow = fc.get('day_of_week', '')
|
||||
otb_rev = fc.get('current_otb_rev')
|
||||
forecast_rev = fc.get('forecast')
|
||||
py_rev = fc.get('prior_year_final_rev')
|
||||
pace = fc.get('pace_vs_prior_pct')
|
||||
budget_val = budgets.get(f"{d}_net_accom")
|
||||
lost = fc.get('lost_potential') or 0
|
||||
|
||||
otb_str = f"£{otb_rev:,.0f}" if otb_rev is not None else "-"
|
||||
fc_str = f"£{forecast_rev:,.0f}" if forecast_rev is not None else "-"
|
||||
py_str = f"£{py_rev:,.0f}" if py_rev is not None else "-"
|
||||
pace_str = f"{pace:+.0f}%" if pace is not None else "-"
|
||||
bud_str = f"£{budget_val:,.0f}" if budget_val is not None else "-"
|
||||
opp_str = f"£{lost:,.0f} left on table" if fc.get('has_pricing_opportunity') else "-"
|
||||
|
||||
lines.append(
|
||||
f"{_fmt_date(d)} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str} | {opp_str}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
total_forecast = sum(fc.get('forecast', 0) or 0 for fc in revenue)
|
||||
total_otb = sum(fc.get('current_otb_rev', 0) or 0 for fc in revenue)
|
||||
total_py = sum(fc.get('prior_year_final_rev', 0) or 0 for fc in revenue)
|
||||
opportunity_days = sum(1 for fc in revenue if fc.get('has_pricing_opportunity'))
|
||||
total_lost = sum(fc.get('lost_potential', 0) or 0 for fc in revenue)
|
||||
|
||||
lines.append("## Revenue Signals")
|
||||
lines.append(f"14-day forecast: £{total_forecast:,.0f} | OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}")
|
||||
lines.append("## Revenue Signals (aggregate)")
|
||||
lines.append(
|
||||
f"{FORECAST_HORIZON_DAYS}-day forecast: £{total_forecast:,.0f} | "
|
||||
f"OTB: £{total_otb:,.0f} | PY: £{total_py:,.0f}"
|
||||
)
|
||||
if opportunity_days > 0:
|
||||
lines.append(f"Pricing opportunity days: {opportunity_days} | Total lost potential: £{total_lost:,.0f}")
|
||||
lines.append("")
|
||||
|
|
@ -258,8 +414,8 @@ def build_prompt(
|
|||
rack_rates = competitor.get('rack', {})
|
||||
|
||||
if own_booking or comp_rates:
|
||||
lines.append("## Competitor Rates (next 14 days)")
|
||||
lines.append("Date | Own Rack | Own B.com | Cheapest Competitor | Competitor Name")
|
||||
lines.append(f"## Competitor Rates (next {FORECAST_HORIZON_DAYS} days)")
|
||||
lines.append("Date | Own Rack | Own B.com | Cheapest Competitor (B.com) | Competitor Name")
|
||||
|
||||
all_dates = sorted(set(list(own_booking.keys()) + list(comp_rates.keys()) + list(rack_rates.keys())))
|
||||
for d in all_dates:
|
||||
|
|
@ -279,6 +435,9 @@ def build_prompt(
|
|||
lines.append(f"{_fmt_date(d)} | {rack_str} | {own_str} | {comp_str} | {comp_name}{note}")
|
||||
lines.append("")
|
||||
|
||||
# Market movement since last insight (same-channel deltas only)
|
||||
lines.extend(build_market_movement_section(competitor, (previous_insight or {}).get('data_snapshot', {}).get('competitor_rates')))
|
||||
|
||||
user_msg = "\n".join(lines)
|
||||
return system_msg, user_msg
|
||||
|
||||
|
|
@ -309,9 +468,20 @@ async def call_llm(api_key: str, system_msg: str, user_msg: str, model: str) ->
|
|||
await client.close()
|
||||
|
||||
|
||||
def parse_headline(raw_content: str) -> tuple[Optional[str], str]:
|
||||
"""Split a `HEADLINE: ...` first line off the model's response. Returns (headline, remaining_content)."""
|
||||
lines = raw_content.split("\n")
|
||||
if lines and lines[0].strip().upper().startswith("HEADLINE:"):
|
||||
headline = lines[0].split(":", 1)[1].strip()
|
||||
remaining = "\n".join(lines[1:]).strip()
|
||||
return headline, remaining
|
||||
return None, raw_content
|
||||
|
||||
|
||||
async def save_insight(
|
||||
db: AsyncSession,
|
||||
content: str,
|
||||
headline: Optional[str],
|
||||
model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
|
|
@ -322,12 +492,13 @@ async def save_insight(
|
|||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO ai_insights
|
||||
(content, model, input_tokens, output_tokens, data_snapshot, triggered_by)
|
||||
VALUES (:content, :model, :input_tokens, :output_tokens,
|
||||
(content, headline, model, input_tokens, output_tokens, data_snapshot, triggered_by)
|
||||
VALUES (:content, :headline, :model, :input_tokens, :output_tokens,
|
||||
CAST(:data_snapshot AS jsonb), :triggered_by)
|
||||
"""),
|
||||
{
|
||||
"content": content,
|
||||
"headline": headline,
|
||||
"model": model,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
|
|
@ -377,22 +548,30 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
|
|||
|
||||
# Gather data
|
||||
logger.info("Gathering data for AI insight...")
|
||||
previous_insight = await get_previous_insight(db)
|
||||
occupancy = await gather_occupancy_data(db)
|
||||
revenue = await gather_revenue_data(db)
|
||||
budgets = await gather_budget_data(db)
|
||||
competitor = await gather_competitor_data(db)
|
||||
|
||||
today = date.today()
|
||||
end = today + timedelta(days=FORECAST_HORIZON_DAYS - 1)
|
||||
bank_holidays = get_uk_bank_holidays(today, end)
|
||||
|
||||
if not occupancy and not revenue:
|
||||
return {"success": False, "error": "No forecast data available"}
|
||||
|
||||
# Build prompt
|
||||
system_msg, user_msg = build_prompt(occupancy, revenue, budgets, competitor)
|
||||
system_msg, user_msg = build_prompt(
|
||||
occupancy, revenue, budgets, competitor, bank_holidays, previous_insight
|
||||
)
|
||||
|
||||
# Store data snapshot for debugging
|
||||
# Store data snapshot — full competitor rates so the NEXT insight can diff against it
|
||||
data_snapshot = {
|
||||
"occupancy_days": len(occupancy),
|
||||
"revenue_days": len(revenue),
|
||||
"competitor_dates": len(competitor.get('own_booking', {})),
|
||||
"competitor_rates": competitor,
|
||||
"prompt_preview": user_msg[:500],
|
||||
}
|
||||
|
||||
|
|
@ -404,10 +583,13 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
|
|||
logger.error(f"LLM call failed: {e}")
|
||||
return {"success": False, "error": f"LLM call failed: {str(e)}"}
|
||||
|
||||
headline, content = parse_headline(result["content"])
|
||||
|
||||
# Save
|
||||
await save_insight(
|
||||
db,
|
||||
content=result["content"],
|
||||
content=content,
|
||||
headline=headline,
|
||||
model=result["model"],
|
||||
input_tokens=result["input_tokens"],
|
||||
output_tokens=result["output_tokens"],
|
||||
|
|
@ -428,7 +610,8 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") ->
|
|||
|
||||
return {
|
||||
"success": True,
|
||||
"content": result["content"],
|
||||
"content": content,
|
||||
"headline": headline,
|
||||
"input_tokens": result["input_tokens"],
|
||||
"output_tokens": result["output_tokens"],
|
||||
"model": result["model"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue