126 lines
6.0 KiB
Python
126 lines
6.0 KiB
Python
# Prompt template and injection logic for the adventure travel
|
|
# vertical — ported unchanged from the legacy app.py. Per CLAUDE.md
|
|
# §10, pivoting to a new industry means adding a new folder under
|
|
# industries/ with these same three files; core/ is never touched.
|
|
|
|
|
|
def build_prompt(competitor_name, page_data, product_name,
|
|
destination, duration, travel_style, company_name=None, is_ngs=False):
|
|
"""
|
|
Builds the full extraction prompt for one competitor page, embedding
|
|
the scraped page content directly into the returned template.
|
|
|
|
company_name is the tenant's own company (jobs.py's context['company_name'],
|
|
fetched from the tenant record) — this was hardcoded to "G Adventures"
|
|
(the hackathon origin client, CLAUDE.md's "Primary reference client")
|
|
until this fix, meaning every other tenant's analysis prompt was
|
|
factually wrong about whose product it was comparing against. Defaults
|
|
to a generic placeholder only for the dry-run /preview-prompt route,
|
|
which has no tenant context to draw a real name from.
|
|
|
|
Data flow:
|
|
competitor_name + page_data (scraped text/tables/url) + client
|
|
product context (name/destination/duration/style/company) + is_ngs
|
|
flag → NGS-specific field block appended if is_ngs →
|
|
full prompt string returned, ready to send to core.extractor
|
|
"""
|
|
company_name = company_name or 'the client'
|
|
ngs_fields = '''
|
|
"exclusiveAccess": "exclusive access or special unique inclusions",
|
|
"groupLeader": "group leader and/or local expert description",
|
|
"sustainability": "sustainability inclusions or null",''' if is_ngs else ''
|
|
|
|
content = page_data['text'] if page_data else '[PAGE CONTENT WILL BE INSERTED HERE]'
|
|
if page_data and page_data.get('tables'):
|
|
content += f'\n\nTABLE DATA:\n{page_data["tables"]}'
|
|
|
|
url = page_data['url'] if page_data else '[COMPETITOR URL]'
|
|
|
|
return f'''You are a travel industry analyst helping {company_name} research competitors.
|
|
Be concise — use short phrases not full sentences for all fields except comments.
|
|
Read the ENTIRE page content carefully before extracting any fields.
|
|
Pay particular attention to itinerary sections, departure tables, and pricing grids
|
|
which may appear later in the content.
|
|
|
|
The content below was scraped from the {competitor_name} tour page at: {url}
|
|
|
|
This is the specific page selected as most comparable to:
|
|
- {company_name} Product: {product_name}
|
|
- Destination: {destination}
|
|
- Duration: ~{duration} days
|
|
- {company_name} Travel Style: {travel_style}
|
|
|
|
PAGE CONTENT:
|
|
---
|
|
{content}
|
|
---
|
|
|
|
CRITICAL PRICING RULES:
|
|
- majorityPrice: Standard/regular adult price for the most common departure.
|
|
IGNORE sale, promotional, early bird, or "was/now" pricing. Use non-discounted rack rate only.
|
|
- Prices may appear as "from $X", "per person from $X", or inside a departures table or pricing grid.
|
|
Extract the most commonly listed standard adult price regardless of how it is labelled on the page.
|
|
- priceLow: Lowest standard price across all departures shown (not sale price).
|
|
- priceHigh: Highest standard price across all departures shown (not sale price).
|
|
- If a site requires clicking "see more departures" to show all dates, note this in seasonality
|
|
and use only prices visible on the main listing page.
|
|
- Multi-currency: only fill if the price is explicitly listed in that currency on the page.
|
|
Do not convert. If the page shows GBP only, fill gbpPrice and leave USD fields null.
|
|
|
|
MEALS RULE:
|
|
- meals: Return the TOTAL number of included meals as a single integer only. No text description.
|
|
- Meals may be listed individually per day in the itinerary section
|
|
(e.g. "Day 1: Breakfast", "Day 3: Breakfast and Dinner").
|
|
Count every individual meal mention across all itinerary days.
|
|
Do not rely on a summary box — read through the full itinerary to count.
|
|
- Count each meal type separately: 1 breakfast + 1 lunch + 1 dinner = 3 meals.
|
|
|
|
Return ONLY a valid JSON object. No markdown, no code fences, no explanation.
|
|
|
|
{{
|
|
"company": "{competitor_name}",
|
|
"link": "{url}",
|
|
"tripName": "exact trip name from the page",
|
|
"tripCode": "trip code if shown, otherwise null",
|
|
"serviceLevel": "accommodation service level",
|
|
"groupSize": max group size as integer,
|
|
"duration": number of days as integer,
|
|
"meals": total included meals as integer,{ngs_fields}
|
|
"startLocation": "departure city",
|
|
"endLocation": "end city",
|
|
"departures": approximate departures per year as integer or null,
|
|
"seasonality": "price bands by season. Note if more departures are hidden behind a button.",
|
|
"startDays": "departure days of week",
|
|
"targetAudience": "target traveller",
|
|
"hotels": "hotel types or properties",
|
|
"activities": "key included activities",
|
|
"relevancy": relevancy score 1-5 as integer,
|
|
"majorityPrice": standard adult USD price as number or null,
|
|
"priceLow": lowest standard USD price as number or null,
|
|
"priceHigh": highest standard USD price as number or null,
|
|
"comments": "analysis: key differences from {company_name}, strengths, weaknesses, pricing position",
|
|
"dateUsed": "specific departure date used for majority price",
|
|
"audPrice": standard AUD price as number or null,
|
|
"cadPrice": standard CAD price as number or null,
|
|
"eurPrice": standard EUR price as number or null,
|
|
"gbpPrice": standard GBP price as number or null
|
|
}}'''
|
|
|
|
|
|
def inject_page_content(prompt_template, competitor_name, page_data):
|
|
"""
|
|
Injects real scraped content into a user-edited prompt template
|
|
(used when the PM has customised the prompt via the dashboard's
|
|
prompt preview/edit flow instead of using build_prompt()'s default).
|
|
"""
|
|
content = page_data['text']
|
|
if page_data.get('tables'):
|
|
content += f'\n\nTABLE DATA:\n{page_data["tables"]}'
|
|
result = prompt_template.replace('[PAGE CONTENT WILL BE INSERTED HERE]', content)
|
|
result = result.replace('[COMPETITOR URL]', page_data['url'])
|
|
result = result.replace(
|
|
competitor_name + ' tour page at: [COMPETITOR URL]',
|
|
competitor_name + f' tour page at: {page_data["url"]}'
|
|
)
|
|
return result
|