# 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, own_trip=None): """ 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. own_trip is the tenant's own trip, ACTUALLY extracted from their own site by embedding_service.scrape_own_trip() (see jobs.py's context['own_trip']) — not just the PM's typed destination/duration/ style strings. When present, the comparison block and the `comments` field instruction reference these real facts instead of only the typed intent, so the LLM compares against ground truth rather than a guess. None (the default) preserves the exact prior behavior for every existing call site and for tenants who haven't confirmed an own-trip URL for this run. Data flow: competitor_name + page_data (scraped text/tables/url) + client product context (name/destination/duration/style/company) + is_ngs flag + optional own_trip (real extracted client data) → NGS-specific field block appended if is_ngs → own_trip block appended if present → 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]' own_trip_block = '' comments_target = f'{company_name}' if own_trip: own_trip_name = own_trip.get('tripName') or product_name own_trip_price = own_trip.get('majorityPrice') own_trip_duration = own_trip.get('duration') or duration own_trip_activities = own_trip.get('activities') own_trip_block = f''' {company_name}'s OWN trip, actually extracted from their own site (real data, not a typed description) — use this as the ground truth for comparison: - Trip name: {own_trip_name} - Price: {own_trip_price if own_trip_price is not None else 'not extracted'} USD (majority price) - Duration: {own_trip_duration} days - Service level: {own_trip.get('serviceLevel') or 'not extracted'} - Activities: {own_trip_activities or 'not extracted'}''' comments_target = f"{company_name}'s own {own_trip_name} (${own_trip_price}, {own_trip_duration} days: {own_trip_activities})" 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}{own_trip_block} 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 {comments_target}, 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 build_own_trip_prompt(page_data, product_name, destination, duration, travel_style, company_name, is_ngs=False): """ Builds the extraction prompt for the TENANT'S OWN trip page — embedding_service.scrape_own_trip() calls this, not build_prompt(), because the framing is different: this is not "researching a competitor," it's extracting structured data from the client's own product so later competitor extractions (build_prompt()'s own_trip param) have real ground truth to compare against instead of typed strings. Shares the same pricing/meals extraction rules as build_prompt() so the numbers are directly comparable, but the returned JSON schema drops `relevancy` (relevance to itself is meaningless) and `comments` (nothing to compare against yet at this stage). Data flow: page_data (scraped from the tenant's own confirmed/matched URL) + typed intent (product_name/destination/duration/travel_style) + company_name + is_ngs → full prompt string, ready to send to core.extractor.extract_with_openrouter() """ 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 '[OWN TRIP URL]' return f'''You are a travel industry analyst extracting structured trip data from {company_name}'s own tour page — this is {company_name}'s own product, not a competitor's. Be concise — use short phrases, not full sentences. 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 {company_name}'s own tour page at: {url} This is the page selected as matching: - Product: {product_name} - Destination: {destination} - Duration: ~{duration} days - 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": "{company_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", "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, "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