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
|
|
@ -28,7 +28,7 @@ async def get_latest_insight(
|
|||
"""Get the most recent AI insight for the dashboard card."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, generated_at, insight_type, content, model,
|
||||
SELECT id, generated_at, insight_type, headline, content, model,
|
||||
input_tokens, output_tokens, triggered_by
|
||||
FROM ai_insights
|
||||
ORDER BY generated_at DESC
|
||||
|
|
@ -43,6 +43,7 @@ async def get_latest_insight(
|
|||
"id": row.id,
|
||||
"generated_at": row.generated_at.isoformat(),
|
||||
"insight_type": row.insight_type,
|
||||
"headline": row.headline,
|
||||
"content": row.content,
|
||||
"model": row.model,
|
||||
"input_tokens": row.input_tokens,
|
||||
|
|
@ -61,7 +62,7 @@ async def get_insight_history(
|
|||
"""Get historical AI insights with pagination."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, generated_at, insight_type, content, model,
|
||||
SELECT id, generated_at, insight_type, headline, content, model,
|
||||
input_tokens, output_tokens, triggered_by
|
||||
FROM ai_insights
|
||||
ORDER BY generated_at DESC
|
||||
|
|
@ -79,6 +80,7 @@ async def get_insight_history(
|
|||
"id": row.id,
|
||||
"generated_at": row.generated_at.isoformat(),
|
||||
"insight_type": row.insight_type,
|
||||
"headline": row.headline,
|
||||
"content": row.content,
|
||||
"model": row.model,
|
||||
"input_tokens": row.input_tokens,
|
||||
|
|
@ -166,6 +168,7 @@ async def generate_insight_manual(
|
|||
return {
|
||||
"success": True,
|
||||
"content": result["content"],
|
||||
"headline": result.get("headline"),
|
||||
"input_tokens": result["input_tokens"],
|
||||
"output_tokens": result["output_tokens"],
|
||||
"model": result["model"],
|
||||
|
|
|
|||
|
|
@ -1223,7 +1223,7 @@ class AIInsightsSettingsResponse(BaseModel):
|
|||
api_key_set: bool = False
|
||||
model: str = "claude-haiku-4-5-20251001"
|
||||
schedule_time: str = "07:15"
|
||||
daily_token_budget: int = 5000
|
||||
daily_token_budget: int = 12000
|
||||
|
||||
|
||||
class AIInsightsSettingsUpdate(BaseModel):
|
||||
|
|
@ -1258,7 +1258,7 @@ async def get_ai_insights_settings(
|
|||
api_key_set=config.get('_api_key_set', False),
|
||||
model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'),
|
||||
schedule_time=config.get('ai_insights_schedule_time', '07:15'),
|
||||
daily_token_budget=int(config.get('ai_insights_daily_token_budget', '5000')),
|
||||
daily_token_budget=int(config.get('ai_insights_daily_token_budget', '12000')),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Auth is handled by the central HNF stack cookie (hnf_session).
|
|||
"""
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
logging.basicConfig(
|
||||
|
|
@ -105,6 +107,9 @@ async def lifespan(app: FastAPI):
|
|||
CREATE INDEX IF NOT EXISTS idx_ai_insights_generated
|
||||
ON ai_insights(generated_at DESC)
|
||||
"""))
|
||||
db.execute(text("""
|
||||
ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS headline TEXT
|
||||
"""))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -122,6 +127,7 @@ app = FastAPI(
|
|||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
STARTED_AT = str(int(time.time() * 1000))
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
|
@ -155,4 +161,4 @@ app.include_router(ai_insights.router, prefix="/ai-insights", tags=[
|
|||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "service": "forecasting-api"}
|
||||
return {"status": "healthy", "service": "forecasting-api", "version": os.environ.get("BUILD_VERSION", STARTED_AT)}
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ python-dotenv==1.0.0
|
|||
python-dateutil==2.8.2
|
||||
playwright>=1.40.0
|
||||
anthropic>=0.42.0
|
||||
holidays>=0.47
|
||||
|
|
|
|||
93
frontend/src/components/AIInsightHistory.tsx
Normal file
93
frontend/src/components/AIInsightHistory.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { History, ChevronDown, ChevronRight, Clock } from 'lucide-react'
|
||||
import api from '../api'
|
||||
import { AIInsight, formatAge, renderContent } from '../utils/aiInsight'
|
||||
|
||||
interface HistoryResponse {
|
||||
insights: AIInsight[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export default function AIInsightHistory() {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery<HistoryResponse>({
|
||||
queryKey: ['ai-insights-history'],
|
||||
queryFn: () => api.get('/ai-insights/history', { params: { limit: 20 } }).then(r => r.data),
|
||||
})
|
||||
|
||||
const insights = data?.insights ?? []
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<div className="card-header">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<History size={16} strokeWidth={1.75} color="var(--gold)" />
|
||||
Insight History
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-body" style={{ padding: 0 }}>
|
||||
{isLoading && (
|
||||
<div className="loading-state" style={{ padding: 16 }}>
|
||||
<div className="spinner" />
|
||||
Loading history…
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && insights.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: 16 }}>
|
||||
<p>No past insights yet.</p>
|
||||
</div>
|
||||
)}
|
||||
{insights.map((item, i) => {
|
||||
const isOpen = expandedId === item.id
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
borderTop: i === 0 ? 'none' : '1px solid var(--card-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedId(isOpen ? null : item.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={14} strokeWidth={1.75} color="var(--text-mid)" style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<ChevronRight size={14} strokeWidth={1.75} color="var(--text-mid)" style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-dark)' }}>
|
||||
{item.headline || 'Daily briefing'}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-mid)', flexShrink: 0 }}>
|
||||
<Clock size={11} strokeWidth={1.75} />
|
||||
{formatAge(item.generated_at)}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div style={{ padding: '0 16px 16px 40px', fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-dark)' }}>
|
||||
{renderContent(item.content)}
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 12 }}>
|
||||
{item.input_tokens}↑ / {item.output_tokens}↓ tokens · {item.triggered_by}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,54 +2,8 @@ import { useState } from 'react'
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { RefreshCw, Bot, Clock } from 'lucide-react'
|
||||
import api from '../api'
|
||||
|
||||
interface AIInsight {
|
||||
id: number
|
||||
generated_at: string
|
||||
content: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
triggered_by: string
|
||||
}
|
||||
|
||||
function formatAge(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(ms / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function renderContent(text: string) {
|
||||
return text.split('\n').map((line, i) => {
|
||||
const safe = escHtml(line)
|
||||
const processed = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ color: 'var(--gold)', flexShrink: 0 }}>•</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: processed.slice(2) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('## ') || line.startsWith('# ')) {
|
||||
const txt = line.replace(/^#+\s*/, '')
|
||||
return <p key={i} style={{ fontWeight: 600, marginTop: 12, marginBottom: 6, color: 'var(--text-dark)' }}>{txt}</p>
|
||||
}
|
||||
if (line.trim() === '') return <div key={i} style={{ height: 8 }} />
|
||||
return <p key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: processed }} />
|
||||
})
|
||||
}
|
||||
import { AIInsight, formatAge, renderContent } from '../utils/aiInsight'
|
||||
import AIInsightHistory from '../components/AIInsightHistory'
|
||||
|
||||
export default function Dashboard() {
|
||||
const qc = useQueryClient()
|
||||
|
|
@ -66,6 +20,7 @@ export default function Dashboard() {
|
|||
onSuccess: () => {
|
||||
setGenError(null)
|
||||
qc.invalidateQueries({ queryKey: ['ai-insights-latest'] })
|
||||
qc.invalidateQueries({ queryKey: ['ai-insights-history'] })
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setGenError(err.response?.data?.detail || 'Failed to generate insight')
|
||||
|
|
@ -125,6 +80,11 @@ export default function Dashboard() {
|
|||
)}
|
||||
{insight && (
|
||||
<>
|
||||
{insight.headline && (
|
||||
<p style={{ fontWeight: 700, fontSize: 15, marginBottom: 12, color: 'var(--text-dark)' }}>
|
||||
{insight.headline}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginBottom: 16 }}>{renderContent(insight.content)}</div>
|
||||
{(insight.input_tokens || insight.output_tokens) && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--card-border)' }}>
|
||||
|
|
@ -136,6 +96,8 @@ export default function Dashboard() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AIInsightHistory />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6156,6 +6156,7 @@ const AIInsightsPage: React.FC = () => {
|
|||
setGenerateMessage(`Generated! ${data.input_tokens} in / ${data.output_tokens} out tokens`)
|
||||
queryClient.invalidateQueries({ queryKey: ['ai-insights-usage'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['ai-insights-latest'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['ai-insights-history'] })
|
||||
} else {
|
||||
setGenerateStatus('error')
|
||||
setGenerateMessage(data.detail || 'Generation failed')
|
||||
|
|
|
|||
49
frontend/src/utils/aiInsight.tsx
Normal file
49
frontend/src/utils/aiInsight.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export interface AIInsight {
|
||||
id: number
|
||||
generated_at: string
|
||||
insight_type: string
|
||||
headline: string | null
|
||||
content: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
triggered_by: string
|
||||
}
|
||||
|
||||
export function formatAge(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(ms / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
export function renderContent(text: string) {
|
||||
return text.split('\n').map((line, i) => {
|
||||
const safe = escHtml(line)
|
||||
const processed = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ color: 'var(--gold)', flexShrink: 0 }}>•</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: processed.slice(2) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('## ') || line.startsWith('# ')) {
|
||||
const txt = line.replace(/^#+\s*/, '')
|
||||
return <p key={i} style={{ fontWeight: 600, marginTop: 12, marginBottom: 6, color: 'var(--text-dark)' }}>{txt}</p>
|
||||
}
|
||||
if (line.trim() === '') return <div key={i} style={{ height: 8 }} />
|
||||
return <p key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: processed }} />
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue