Add Excel export, rewrite Sheet pull to transposed layout, enrich battlecards
- SyncToSheet.vue: add "Download Excel" export alongside Copy rows, using exceljs (dynamically imported to avoid bundling ~940KB into AnalysePage) instead of xlsx (unpatched high-severity CVEs) - appscript/Sync.gs: rewrite the Sheet-pull layout from column-per-field/ row-per-competitor to the transposed layout (fields down column A, one column per competitor) — matches the dashboard's Copy rows/Excel export and the real legacy client reference sheet, which the old layout was never actually reconciled against. Row detection is now label-based and self-bootstraps a blank template on first pull. - CLAUDE.md: correct §15 to document the transposed layout instead of the stale column-per-field description - battlecard_service.py: replace two hardcoded placeholder strings (positioning_summary, recent_changes) with real computed data — actual price_history-derived recent changes, and richer product summaries (service levels, target audience, activities). Raise BATTLECARD_PRODUCT_LIMIT 10->50 and loosen prompt word caps so the richer input isn't compressed away - BattlecardCard.vue: render pricing_observation, generated but never displayed before - pb_migrations/009: fix battlecards.generated_at to stamp on update, not just create, so regenerated battlecards show an accurate "Updated" timestamp Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a25909a459
commit
965028e6c2
171
CLAUDE.md
171
CLAUDE.md
|
|
@ -603,7 +603,7 @@ bettersight/
|
|||
├── appscript/ # Sheet-initiated pull integration — 3 files
|
||||
│ ├── Code.gs # onOpen(), install entry point
|
||||
│ ├── Validation.gs # validateLicence() — email only
|
||||
│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectColumns()
|
||||
│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
|
||||
│ └── Sidebar.html # Tab selector + pull button + last-pull status
|
||||
├── n8n/
|
||||
│ └── workflows/ # n8n workflow JSON exports
|
||||
|
|
@ -2371,23 +2371,50 @@ Firecrawl is in the MVP stack for `/monitor` only.
|
|||
|
||||
## 11. Battlecard Generation
|
||||
|
||||
After each scrape run completes, n8n calls `/internal/battlecard/<competitor_id>`.
|
||||
After each scrape run completes (whether triggered by a manual dashboard
|
||||
analysis or the nightly watchlist cron — see §7's "Trip watchlist"),
|
||||
`jobs.py` calls `generate_battlecard()` directly whenever a competitor's
|
||||
refresh path produced genuinely fresh data (real extraction, not a
|
||||
cache reuse and not a hash-confirmed-unchanged result).
|
||||
|
||||
```python
|
||||
def generate_battlecard(competitor_id: str, tenant_id: str) -> dict:
|
||||
"""
|
||||
Pulls latest product data for a competitor from PocketBase.
|
||||
Passes structured data to Claude Haiku via OpenRouter.
|
||||
Saves generated battlecard text and JSON back to PocketBase.
|
||||
Returns battlecard dict for immediate use.
|
||||
Pulls a competitor's tracked products (up to BATTLECARD_PRODUCT_LIMIT
|
||||
= 50, newest first) from PocketBase, plus their real recent
|
||||
price_history, and passes a real structured summary to the
|
||||
OpenRouter extraction model. Saves generated battlecard text and
|
||||
JSON back to PocketBase. Returns battlecard dict for immediate use.
|
||||
|
||||
Data flow:
|
||||
competitor_id → PocketBase products (latest 10) →
|
||||
structured prompt → OpenRouter (claude-haiku) →
|
||||
competitor_id → PocketBase products (latest 50) →
|
||||
_summarise_products() (top trips, trip count, price range, avg
|
||||
duration, service levels, target audiences, activities) +
|
||||
_summarise_recent_price_changes() (real price_history moves in
|
||||
the last 30 days, sorted by magnitude) →
|
||||
structured prompt → OpenRouter (via core.extractor) →
|
||||
battlecard text + JSON → PocketBase battlecards table
|
||||
"""
|
||||
```
|
||||
|
||||
`BATTLECARD_PRODUCT_LIMIT` was previously 10 — skewed the summary
|
||||
toward whichever products happened to be scraped most recently instead
|
||||
of a reasonably complete view of the tracked catalogue. Raised to 50
|
||||
(adjustable here only).
|
||||
|
||||
`{positioning_summary}` and `{recent_changes}` were previously hardcoded
|
||||
placeholder strings (`'Derived from current product catalogue.'` and
|
||||
`'See price_history for this competitor.'`) — the model was being told
|
||||
those inputs existed when they never varied per competitor or per run.
|
||||
`positioning_summary` is removed outright (it was also circular — the
|
||||
prompt's own *output* already asks the model to derive `"positioning"`).
|
||||
`recent_changes` is now a real sentence built from that competitor's
|
||||
actual `price_history` rows within the last 30 days (every row already
|
||||
represents a genuine price move — `_save_scrape_result()` only writes
|
||||
one when the price actually changed — so no magnitude filter is needed,
|
||||
just the recency window), falling back to "No significant price changes
|
||||
in the last 30 days." when none qualify.
|
||||
|
||||
Prompt template:
|
||||
```
|
||||
You are a competitive intelligence analyst for an adventure travel operator.
|
||||
|
|
@ -2395,16 +2422,22 @@ Based on this competitor data, write a concise battlecard.
|
|||
|
||||
Competitor: {name}
|
||||
Top trips: {top_trips}
|
||||
Number of tracked trips: {trip_count}
|
||||
Price range: {price_low} - {price_high} USD
|
||||
Avg duration: {avg_duration} days
|
||||
Positioning: {positioning_summary}
|
||||
Recent changes: {recent_changes}
|
||||
Service levels offered: {service_levels}
|
||||
Typical target audience: {target_audiences}
|
||||
Common activities/themes: {activities_summary}
|
||||
Recent price changes (last 30 days): {recent_changes}
|
||||
|
||||
Write:
|
||||
1. One line positioning summary (max 15 words)
|
||||
2. Three strengths (bullet points, max 8 words each)
|
||||
3. Three weaknesses (bullet points, max 8 words each)
|
||||
4. One pricing observation (max 20 words)
|
||||
Write — ground every point in the specific data above (real prices,
|
||||
service levels, activities, audience, recent price moves) rather than
|
||||
generic statements that could apply to any competitor:
|
||||
1. One line positioning summary (max 20 words)
|
||||
2. Three strengths (bullet points, max 14 words each)
|
||||
3. Three weaknesses (bullet points, max 14 words each)
|
||||
4. One pricing observation (max 30 words) — reference the actual price
|
||||
range and/or a real recent price change when one is available
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
|
|
@ -2415,6 +2448,32 @@ Return ONLY valid JSON:
|
|||
}
|
||||
```
|
||||
|
||||
Word limits were previously 15/8/8/20 — tight enough that the richer
|
||||
input data above (added in the same pass as removing the placeholder
|
||||
strings) was mostly getting compressed away into generic-sounding
|
||||
fragments rather than actually showing up in the output. Loosened once
|
||||
the input data itself became real; still a scannable card, not a full
|
||||
narrative (that's what the per-run `comments` field, §7, already is —
|
||||
the two are intentionally different: `comments` is a one-off comparison
|
||||
for a specific trip match-up, a battlecard is an evergreen standing
|
||||
summary of a competitor overall).
|
||||
|
||||
`BattlecardCard.vue` previously rendered `positioning` and the
|
||||
`strengths`/`weaknesses` pills only — `content_json.pricing_observation`
|
||||
was generated and stored the whole time but never actually displayed
|
||||
anywhere on the card. Now rendered as its own line below the pills.
|
||||
|
||||
`battlecards.generated_at` was `autodate('generated_at', true)` —
|
||||
onCreate only, never onUpdate (migration 001's `autodate()` helper
|
||||
defaults `onUpdate` to `false`). Every regeneration since a
|
||||
battlecard's first creation genuinely updated `content`/`content_json`
|
||||
underneath while the dashboard's "Updated {generated_at}" timestamp
|
||||
stayed frozen at the original creation date — a real battlecard
|
||||
refresh could look untouched for days. Fixed via migration 009
|
||||
(in-place field mutation — `collection.fields.getByName('generated_at').onUpdate = true`
|
||||
— not a destructive remove+re-add, which would have wiped every
|
||||
existing battlecard's real creation timestamp for no benefit).
|
||||
|
||||
---
|
||||
|
||||
## 12. Semantic Trip Matching
|
||||
|
|
@ -2605,8 +2664,8 @@ their workbook exactly as before.
|
|||
|
||||
**The PM can rename the imported tab to anything.**
|
||||
The sidebar's tab selector dropdown reads live tab names via
|
||||
getWorkbookTabs() every time it opens, and detectColumns() maps
|
||||
data to columns by header name — never by tab name or column position.
|
||||
getWorkbookTabs() every time it opens, and detectFieldRows_() maps
|
||||
data to rows by label — never by tab name or row position.
|
||||
Rename the tab "Comp Intel", "Peru Research 2026", or leave it as
|
||||
"Bettersight Analysis" — pullLatestResults() writes correctly either
|
||||
way as long as the PM selects the intended tab from the dropdown.
|
||||
|
|
@ -2637,25 +2696,47 @@ way as long as the PM selects the intended tab from the dropdown.
|
|||
The template Sheet we host on Google Drive contains a single tab
|
||||
named Bettersight Analysis by default (PM can rename after import).
|
||||
|
||||
**Column structure:** Row 1 is a bold header row with column names
|
||||
matching STANDARD_FIELDS in the exact order the extractor produces.
|
||||
Rows 2+ are empty and will be populated by pullLatestResults() based
|
||||
on header-name column detection.
|
||||
**Row structure — TRANSPOSED, not column-per-field.** Column A holds
|
||||
one row per field label (company, link, trip name, pricing fields,
|
||||
etc. — the exact list and order live in `appscript/Sync.gs`'s
|
||||
`STANDARD_ROWS`/`NGS_ROWS`, hand-ported from the dashboard's own
|
||||
`frontend/src/config/resultFields.js`). Row 1 is reserved for
|
||||
competitor names, starting at column B — one column per competitor in
|
||||
the most recent run. This matches the dashboard's Copy rows /
|
||||
Download Excel export exactly, both of which were verified against
|
||||
the real legacy client reference sheet ("G Compete App V1.xlsx"). An
|
||||
earlier version of this section (and the `detectColumns()` design it
|
||||
described) specified the opposite layout — column-per-field headers
|
||||
in row 1, one row appended per competitor — which was never actually
|
||||
reconciled against that real reference file; `Sync.gs` was rewritten
|
||||
to match the transposed layout instead of the reverse.
|
||||
|
||||
The template tab does not need to be pre-populated with row labels at
|
||||
all — a blank tab is enough. `detectFieldRows_()` bootstraps column A
|
||||
from `STANDARD_ROWS`/`NGS_ROWS` (setting the 'Field' corner label in
|
||||
A1) the first time it finds no recognisable labels there, so the very
|
||||
first pull self-describes the sheet. Every subsequent pull REPLACES
|
||||
the previously-written competitor columns (clears columns B onward)
|
||||
rather than appending new columns indefinitely — the tab always
|
||||
reflects the single most recent run, same as the dashboard's results
|
||||
view.
|
||||
|
||||
**Header resilience:**
|
||||
- If the PM renames a column header, detectColumns() still finds it
|
||||
as long as the header name is one of the recognised aliases in
|
||||
detectColumns()'s header alias map
|
||||
- If the PM inserts new columns, reorders columns, or deletes optional
|
||||
columns, detectColumns() adapts — never hardcoded column positions
|
||||
- If detectColumns() cannot find a required header, it logs a warning
|
||||
to the sidebar and falls back to appending the missing column at the
|
||||
end of the existing data range with the standard name
|
||||
- If the PM renames a row label, detectFieldRows_() still finds it as
|
||||
long as it matches (case/punctuation-insensitive) the row's label —
|
||||
substituting the PM's real company name into the comments row's
|
||||
`{company}` token first, since that's what's actually written to
|
||||
the cell
|
||||
- If the PM reorders rows or deletes optional rows, detectFieldRows_()
|
||||
adapts — never hardcoded row positions
|
||||
- If detectFieldRows_() cannot find ANY recognisable row label at all
|
||||
(a genuinely blank tab), it bootstraps the full row list from
|
||||
scratch — see above
|
||||
|
||||
**Template hosting:**
|
||||
The template is a single Google Sheets file on Drive at a stable share
|
||||
URL. The URL is stored as an env var TEMPLATE_SHEET_URL and served via
|
||||
GET /account/template. Any updates to the template columns are made
|
||||
GET /account/template. Any updates to the template's row list are made
|
||||
in-place on the source file — no versioning needed since the PM's
|
||||
copy is a snapshot at their moment of import.
|
||||
|
||||
|
|
@ -2665,7 +2746,7 @@ copy is a snapshot at their moment of import.
|
|||
appscript/
|
||||
Code.gs — onOpen(), install entry point
|
||||
Validation.gs — validateLicence() email-only
|
||||
Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
|
||||
Sync.gs — pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
|
||||
Sidebar.html — tab selector + pull button + last-pull status
|
||||
```
|
||||
|
||||
|
|
@ -2677,17 +2758,21 @@ appscript/
|
|||
* and writes it to the selected tab of the currently open workbook.
|
||||
* Sheet-initiated only — cannot be triggered by any external server.
|
||||
*
|
||||
* Writes results using header-based column detection — never
|
||||
* hardcoded column positions. Resilient to any sheet restructuring.
|
||||
* Writes results in the TRANSPOSED layout (one row per field in
|
||||
* column A, one column per competitor starting at column B) — matches
|
||||
* the dashboard's Copy rows / Download Excel export exactly. Row
|
||||
* detection is label-based, never hardcoded row positions —
|
||||
* resilient to any sheet restructuring.
|
||||
*
|
||||
* Flow:
|
||||
* PM clicks [Pull latest results] in sidebar →
|
||||
* validateLicence() confirms authorisation →
|
||||
* GET Flask /research/history/latest?email={sessionEmail} →
|
||||
* stored results returned →
|
||||
* detectColumns(sheet) builds { field: column } map →
|
||||
* writeResults(results, columnMap, sheet) writes into selected tab →
|
||||
* sidebar shows "Pulled: Today 9:14am · 5 competitors · Peru"
|
||||
* stored results + tab_type returned →
|
||||
* detectFieldRows_(sheet, rows) builds { field: rowNumber } map →
|
||||
* writeResultsToSheet_(results, rowMap, rows, sheet) writes into
|
||||
* selected tab, one column per competitor →
|
||||
* sidebar shows "Pulled: Today 9:14am · 5 competitors"
|
||||
*/
|
||||
function pullLatestResults(tabName) { ... }
|
||||
|
||||
|
|
@ -2702,11 +2787,13 @@ function getWorkbookTabs() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Detects column positions by matching header names in row 1.
|
||||
* Returns { fieldName: columnIndex } map.
|
||||
* Falls back to STANDARD_FIELDS positions if headers not found.
|
||||
* Detects each field's row number by matching labels in column A
|
||||
* (rows 2+, row 1 reserved for competitor names). Returns
|
||||
* { fieldKey: rowNumber }. Bootstraps column A from the row list
|
||||
* (and sets the 'Field' corner label in A1) if no labels are found at
|
||||
* all — a blank tab is a valid starting point.
|
||||
*/
|
||||
function detectColumns(sheet) { ... }
|
||||
function detectFieldRows_(sheet, rows, companyName) { ... }
|
||||
```
|
||||
|
||||
### Sidebar UI
|
||||
|
|
@ -3598,7 +3685,7 @@ Phase 3 — Dashboard (PRIMARY — build before Apps Script)
|
|||
Phase 4 — Apps Script (Sheet-initiated pull — simple)
|
||||
31. Deploy as standalone Apps Script — not container-bound to any workbook
|
||||
32. Validation.gs — validateLicence() email-only
|
||||
33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
|
||||
33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
|
||||
34. Sidebar.html — tab selector + [Pull latest results] + last-pull status
|
||||
35. Sheet template tab — importable into any existing workbook
|
||||
36. Flask GET /research/history/latest route (reuses history handler)
|
||||
|
|
|
|||
|
|
@ -5,56 +5,100 @@
|
|||
* button), never from the dashboard or Flask, since a stateless
|
||||
* backend has no way to reach into whichever Sheet a PM has open
|
||||
* (CLAUDE.md §15).
|
||||
*
|
||||
* Layout: TRANSPOSED — one row per field (column A), one column per
|
||||
* competitor (starting at column B), row 1 reserved for competitor
|
||||
* names. This matches the dashboard's Copy rows / Download Excel
|
||||
* export exactly (frontend/src/config/resultFields.js), which was
|
||||
* itself verified against the real legacy client reference sheet
|
||||
* ("G Compete App V1.xlsx") — NOT the column-per-field/row-per-
|
||||
* competitor layout CLAUDE.md §15 originally described, which was
|
||||
* never reconciled against that real reference file. ROW_DEFS below
|
||||
* is a hand-ported mirror of resultFields.js's STANDARD_ROWS/NGS_ROWS
|
||||
* — Apps Script runs in a separate sandbox with no module imports, so
|
||||
* this list is duplicated rather than shared. Keep both in sync if
|
||||
* either changes.
|
||||
*/
|
||||
|
||||
const DEFAULT_TAB_NAME = 'Bettersight Analysis';
|
||||
|
||||
// Every STANDARD_FIELDS column's recognised header aliases, written
|
||||
// out explicitly rather than guessed at mid-scrape. The first alias
|
||||
// in each list is the canonical label used when writing a brand-new
|
||||
// header row. Matching is case- and punctuation-insensitive (see
|
||||
// normaliseHeader_), so "Rack Rate", "rack rate", and "RACK-RATE" all
|
||||
// match the same entry.
|
||||
const FIELD_ALIASES = {
|
||||
company: ['Company', 'Competitor', 'Competitor Name', 'Operator'],
|
||||
link: ['Link', 'URL', 'Page URL', 'Trip URL', 'Web Link'],
|
||||
tripName: ['Trip Name', 'Trip', 'Product Name', 'Tour Name'],
|
||||
tripCode: ['Trip Code', 'Code', 'Product Code', 'SKU'],
|
||||
serviceLevel: ['Service Level', 'Style', 'Accommodation Level', 'Class'],
|
||||
groupSize: ['Group Size', 'Max Group Size', 'Group', 'Party Size'],
|
||||
duration: ['Duration', 'Days', 'Length', 'Trip Length', 'Number of Days'],
|
||||
meals: ['Meals', 'Included Meals', 'Total Meals', 'Meal Count'],
|
||||
startLocation: ['Start Location', 'Departure City', 'Starts', 'Start City', 'Start Point'],
|
||||
endLocation: ['End Location', 'End City', 'Ends', 'Finish City', 'End Point'],
|
||||
departures: ['Departures', 'Departures Per Year', 'Frequency', 'Number of Departures'],
|
||||
seasonality: ['Seasonality', 'Season', 'Price Bands', 'Seasonal Pricing'],
|
||||
startDays: ['Start Days', 'Departure Days', 'Days of Week'],
|
||||
targetAudience: ['Target Audience', 'Audience', 'Traveller Type', 'Traveler Type'],
|
||||
hotels: ['Hotels', 'Accommodation', 'Hotel Type', 'Lodging'],
|
||||
activities: ['Activities', 'Included Activities', 'Key Activities', 'Highlights'],
|
||||
relevancy: ['Relevancy', 'Relevance', 'Match Score', 'Relevance Score'],
|
||||
majorityPrice: ['Majority Price', 'Standard Price', 'Rack Rate', 'Price', 'Price USD', 'Adult Price'],
|
||||
priceLow: ['Price Low', 'Low Price', 'Min Price', 'Lowest Price', 'Price From'],
|
||||
priceHigh: ['Price High', 'High Price', 'Max Price', 'Highest Price', 'Price To'],
|
||||
comments: ['Comments', 'Analysis', 'Notes', 'Observations'],
|
||||
dateUsed: ['Date Used', 'Departure Date', 'Date', 'Sample Date'],
|
||||
audPrice: ['AUD Price', 'AUD', 'Price AUD', 'Australian Dollar Price'],
|
||||
cadPrice: ['CAD Price', 'CAD', 'Price CAD', 'Canadian Dollar Price'],
|
||||
eurPrice: ['EUR Price', 'EUR', 'Price EUR', 'Euro Price'],
|
||||
gbpPrice: ['GBP Price', 'GBP', 'Price GBP', 'British Pound Price', 'Pound Price']
|
||||
};
|
||||
function divOrNull_(a, b) {
|
||||
if (a == null || b == null || b === 0) return null;
|
||||
return a / b;
|
||||
}
|
||||
|
||||
// Fallback column positions, used only when row 1 has no recognisable
|
||||
// headers at all (e.g. a brand-new blank tab). Ordered left to right
|
||||
// starting at column 1 — logs a warning when this path is taken.
|
||||
const STANDARD_FIELDS_ORDER = Object.keys(FIELD_ALIASES);
|
||||
function mulOrNull_(a, b) {
|
||||
if (a == null || b == null) return null;
|
||||
return a * b;
|
||||
}
|
||||
|
||||
const STANDARD_ROWS = [
|
||||
{ key: 'company', label: 'Company', source: 'name' },
|
||||
{ key: 'link', label: 'Link' },
|
||||
{ key: 'tripName', label: 'Trip Name' },
|
||||
{ key: 'tripCode', label: 'Trip Code' },
|
||||
{ key: 'serviceLevel', label: 'Service Level' },
|
||||
{ key: 'groupSize', label: 'Group size' },
|
||||
{ key: 'duration', label: 'Duration (days)' },
|
||||
{ key: 'meals', label: 'Meals' },
|
||||
{ key: 'startLocation', label: 'Start Location' },
|
||||
{ key: 'endLocation', label: 'End Location' },
|
||||
{ key: 'departures', label: '# Departures Offered per year' },
|
||||
{ key: 'totalInventory', label: 'Total inventory', compute: function (r) { return mulOrNull_(r && r.departures, r && r.groupSize); } },
|
||||
{ key: 'seasonality', label: 'Seasonality (what are the price bands in terms of dates?)' },
|
||||
{ key: 'startDays', label: 'Start days of week' },
|
||||
{ key: 'targetAudience', label: 'Target audience if known' },
|
||||
{ key: 'hotels', label: 'Hotels (if applicable)' },
|
||||
{ key: 'activities', label: 'Included Activities' },
|
||||
{ key: 'relevancy', label: 'Relevancy rating' },
|
||||
{ key: 'majorityPrice', label: 'Majority Price (USD)' },
|
||||
{ key: 'priceLow', label: 'Price low (USD)' },
|
||||
{ key: 'priceHigh', label: 'Price high (USD)' },
|
||||
{ key: 'pricePerDay', label: 'Price per day (USD)', compute: function (r) { return divOrNull_(r && r.majorityPrice, r && r.duration); } },
|
||||
{ key: 'comments', label: 'Suggested changes for {company}/ Competitive Comments' },
|
||||
{ key: 'dateUsed', label: 'Date Used for Majority Price' },
|
||||
{ key: 'audPrice', label: 'AUD Majority price' },
|
||||
{ key: 'cadPrice', label: 'CAD Majority price' },
|
||||
{ key: 'eurPrice', label: 'EUR Majority price' },
|
||||
{ key: 'gbpPrice', label: 'GBP Majority price' },
|
||||
{ key: 'audPricePerDay', label: 'AUD Price/day', compute: function (r) { return divOrNull_(r && r.audPrice, r && r.duration); } },
|
||||
{ key: 'cadPricePerDay', label: 'CAD Price/day', compute: function (r) { return divOrNull_(r && r.cadPrice, r && r.duration); } },
|
||||
{ key: 'eurPricePerDay', label: 'EUR Price/day', compute: function (r) { return divOrNull_(r && r.eurPrice, r && r.duration); } },
|
||||
{ key: 'gbpPricePerDay', label: 'GBP Price/day', compute: function (r) { return divOrNull_(r && r.gbpPrice, r && r.duration); } }
|
||||
];
|
||||
|
||||
// Spliced in after 'activities' — matches resultFields.js's insertAfter().
|
||||
const NGS_EXTRA_ROWS = [
|
||||
{ key: 'exclusiveAccess', label: 'Exclusive Access/Special Unique Inclusions' },
|
||||
{ key: 'groupLeader', label: 'Group Leader and/or Local Expert Description' },
|
||||
{ key: 'sustainability', label: 'Sustainability Inclusions (if applicable)' }
|
||||
];
|
||||
|
||||
function getRowsForTabType_(tabType) {
|
||||
if (tabType !== 'NGS') return STANDARD_ROWS;
|
||||
const activitiesIndex = STANDARD_ROWS.findIndex(function (r) { return r.key === 'activities'; });
|
||||
return STANDARD_ROWS.slice(0, activitiesIndex + 1)
|
||||
.concat(NGS_EXTRA_ROWS, STANDARD_ROWS.slice(activitiesIndex + 1));
|
||||
}
|
||||
|
||||
function normaliseHeader_(text) {
|
||||
return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function canonicalLabel_(field) {
|
||||
return FIELD_ALIASES[field][0];
|
||||
/**
|
||||
* Substitutes the '{company}' token in the comments row's label.
|
||||
* Apps Script has no direct route to the tenant's display name (only
|
||||
* the dashboard's Pinia store carries that) — always falls back to
|
||||
* 'your company'. A no-op for every other row (no token present).
|
||||
*/
|
||||
function formatLabel_(label, companyName) {
|
||||
return label.replace('{company}', companyName || 'your company');
|
||||
}
|
||||
|
||||
function rawValue_(row, item) {
|
||||
if (row.compute) return row.compute(item.result || {});
|
||||
if (row.source === 'name') return item.name;
|
||||
return item.result ? item.result[row.key] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,84 +113,85 @@ function getWorkbookTabs() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Detects the column index for each field by matching header names in
|
||||
* row 1 of the target sheet against FIELD_ALIASES — never hardcoded
|
||||
* column positions. Falls back to STANDARD_FIELDS_ORDER's left-to-right
|
||||
* positions only when row 1 has no recognisable headers at all — logs
|
||||
* a warning when that fallback is used.
|
||||
* Detects the row index for each field by matching normalised labels
|
||||
* in column A (rows 2+, row 1 is reserved for competitor names)
|
||||
* against `rows` — never hardcoded row positions, so a PM can reorder
|
||||
* or delete rows freely. Falls back to `rows`' own order, starting at
|
||||
* row 2, only when column A has no recognisable labels at all (e.g. a
|
||||
* brand-new blank tab) — writes those labels in on the spot (and sets
|
||||
* the 'Field' corner label in A1) so the sheet becomes self-describing
|
||||
* from that point on. A PM never has to hand-type the ~30 row labels
|
||||
* themselves — leaving the tab blank and pulling once is enough.
|
||||
*
|
||||
* Data flow:
|
||||
* sheet → read row 1 → normalise each header cell →
|
||||
* for each field, check every alias in FIELD_ALIASES against every
|
||||
* header cell → first match wins → column index →
|
||||
* { fieldName: columnIndex } map returned
|
||||
* sheet + rows + companyName → read column A (rows 2..lastRow) →
|
||||
* normalise each cell → for each row def, match against its
|
||||
* (company-substituted) label → { fieldKey: rowNumber } map →
|
||||
* if nothing matched at all: bootstrap column A from `rows` in
|
||||
* order → same map, now fully populated
|
||||
*/
|
||||
function detectColumns(sheet) {
|
||||
const lastColumn = Math.max(sheet.getLastColumn(), 1);
|
||||
const headerRow = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
|
||||
const normalisedHeaders = headerRow.map(normaliseHeader_);
|
||||
function detectFieldRows_(sheet, rows, companyName) {
|
||||
const lastRow = sheet.getLastRow();
|
||||
const rowMap = {};
|
||||
|
||||
const columnMap = {};
|
||||
Object.keys(FIELD_ALIASES).forEach(function (field) {
|
||||
const normalisedAliases = FIELD_ALIASES[field].map(normaliseHeader_);
|
||||
for (let col = 0; col < normalisedHeaders.length; col++) {
|
||||
if (normalisedHeaders[col] && normalisedAliases.indexOf(normalisedHeaders[col]) !== -1) {
|
||||
columnMap[field] = col + 1; // 1-indexed columns
|
||||
if (lastRow >= 2) {
|
||||
const columnA = sheet.getRange(2, 1, lastRow - 1, 1).getValues().map(function (r) { return r[0]; });
|
||||
const normalisedColumn = columnA.map(normaliseHeader_);
|
||||
|
||||
rows.forEach(function (row) {
|
||||
const target = normaliseHeader_(formatLabel_(row.label, companyName));
|
||||
for (let i = 0; i < normalisedColumn.length; i++) {
|
||||
if (normalisedColumn[i] && normalisedColumn[i] === target) {
|
||||
rowMap[row.key] = i + 2; // +2: back to 1-indexed, offset past row 1
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const foundAnyHeader = Object.keys(columnMap).length > 0;
|
||||
if (!foundAnyHeader) {
|
||||
Logger.log('detectColumns: no recognisable headers in row 1 — falling back to STANDARD_FIELDS_ORDER positions');
|
||||
STANDARD_FIELDS_ORDER.forEach(function (field, i) {
|
||||
columnMap[field] = i + 1;
|
||||
const foundAny = Object.keys(rowMap).length > 0;
|
||||
if (!foundAny) {
|
||||
Logger.log('detectFieldRows_: no recognisable row labels in column A — bootstrapping a fresh template');
|
||||
sheet.getRange(1, 1).setValue('Field');
|
||||
rows.forEach(function (row, i) {
|
||||
const rowNumber = i + 2;
|
||||
sheet.getRange(rowNumber, 1).setValue(formatLabel_(row.label, companyName));
|
||||
rowMap[row.key] = rowNumber;
|
||||
});
|
||||
}
|
||||
|
||||
return columnMap;
|
||||
return rowMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the header row (if the sheet is empty) and appends one row
|
||||
* per competitor result, using columnMap from detectColumns(). Only
|
||||
* writes fields present in columnMap.
|
||||
* Writes one column per competitor into the sheet, using rowMap from
|
||||
* detectFieldRows_(). Only writes fields present in rowMap — a row the
|
||||
* PM deleted from the template is silently skipped, same resilience
|
||||
* rule the old column-based model used.
|
||||
*
|
||||
* Every pull REPLACES the previously-written competitor columns
|
||||
* (clears columns B onward first) rather than appending indefinitely
|
||||
* — this tab always reflects the single most recent run, same as the
|
||||
* dashboard's results view. Column A's row labels are left untouched.
|
||||
*/
|
||||
function writeResultsToSheet_(results, columnMap, sheet) {
|
||||
if (sheet.getLastRow() === 0) {
|
||||
const headerCells = [];
|
||||
Object.keys(columnMap).forEach(function (field) {
|
||||
headerCells[columnMap[field] - 1] = canonicalLabel_(field);
|
||||
});
|
||||
sheet.getRange(1, 1, 1, headerCells.length).setValues([headerCells.map(function (h) { return h || ''; })]);
|
||||
}
|
||||
|
||||
let nextRow = sheet.getLastRow() + 1;
|
||||
function writeResultsToSheet_(results, rowMap, rows, sheet) {
|
||||
const successItems = (results && results.success) || [];
|
||||
|
||||
successItems.forEach(function (item) {
|
||||
const extracted = item.result || {};
|
||||
const rowValues = {};
|
||||
rowValues.company = item.name;
|
||||
rowValues.link = extracted.link || (item.product && item.product.url) || '';
|
||||
|
||||
Object.keys(FIELD_ALIASES).forEach(function (field) {
|
||||
if (field === 'company' || field === 'link') return;
|
||||
const value = extracted[field];
|
||||
if (value !== undefined && value !== null) {
|
||||
rowValues[field] = value;
|
||||
const lastColumn = sheet.getLastColumn();
|
||||
if (lastColumn > 1) {
|
||||
sheet.getRange(1, 2, sheet.getMaxRows(), lastColumn - 1).clearContent();
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(rowValues).forEach(function (field) {
|
||||
const col = columnMap[field];
|
||||
if (col) {
|
||||
sheet.getRange(nextRow, col).setValue(rowValues[field]);
|
||||
}
|
||||
});
|
||||
successItems.forEach(function (item, index) {
|
||||
const col = index + 2; // column A is labels, competitors start at column B
|
||||
sheet.getRange(1, col).setValue(item.name);
|
||||
|
||||
nextRow += 1;
|
||||
rows.forEach(function (row) {
|
||||
const rowNumber = rowMap[row.key];
|
||||
if (!rowNumber) return;
|
||||
const value = rawValue_(row, item);
|
||||
sheet.getRange(rowNumber, col).setValue(value == null ? '' : value);
|
||||
});
|
||||
});
|
||||
|
||||
return successItems.length;
|
||||
|
|
@ -160,8 +205,9 @@ function writeResultsToSheet_(results, columnMap, sheet) {
|
|||
* Data flow:
|
||||
* validateLicence() → confirms authorisation →
|
||||
* GET {BETTERSIGHT_API_BASE_URL}/research/history/latest?email=... →
|
||||
* stored results returned → target tab resolved (creates it if
|
||||
* missing) → detectColumns(sheet) → writeResultsToSheet_() →
|
||||
* stored results + tab_type returned → target tab resolved (creates
|
||||
* it if missing) → getRowsForTabType_(tab_type) selects the
|
||||
* Standard/NGS row list → detectFieldRows_() → writeResultsToSheet_() →
|
||||
* last-pull status saved to UserProperties, sync_from_sheet
|
||||
* onboarding flag flipped on Flask on first success →
|
||||
* { success, rowsWritten, summary } returned to the sidebar
|
||||
|
|
@ -202,8 +248,9 @@ function pullLatestResults(tabName) {
|
|||
sheet = spreadsheet.insertSheet(targetTabName);
|
||||
}
|
||||
|
||||
const columnMap = detectColumns(sheet);
|
||||
const rowsWritten = writeResultsToSheet_(run.results, columnMap, sheet);
|
||||
const rows = getRowsForTabType_(run.tab_type);
|
||||
const rowMap = detectFieldRows_(sheet, rows, null);
|
||||
const rowsWritten = writeResultsToSheet_(run.results, rowMap, rows, sheet);
|
||||
|
||||
const summary = `${rowsWritten} competitor${rowsWritten === 1 ? '' : 's'}`;
|
||||
const timestamp = new Date().toLocaleString();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@
|
|||
* frontend bundle. Real per-PM authorization is the email check inside
|
||||
* validateLicence() below, not this header.
|
||||
*
|
||||
* Replace these two values before publishing the add-on.
|
||||
* TEMPORARY — pointed at a local dev cloudflared quick tunnel for
|
||||
* testing the pull flow end-to-end before a real production domain
|
||||
* exists. Quick tunnels are not stable: the hostname changes every
|
||||
* time cloudflared restarts, and Cloudflare gives no uptime guarantee
|
||||
* on the free, account-less tier. Replace both values with the real
|
||||
* production API base URL + API_SECRET_KEY before publishing the
|
||||
* add-on for real PM use.
|
||||
*/
|
||||
const BETTERSIGHT_API_BASE_URL = 'https://api.bettersight.io';
|
||||
const BETTERSIGHT_API_KEY = 'REPLACE_WITH_PRODUCTION_API_KEY';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
//
|
||||
// Fixes battlecards.generated_at to also stamp on UPDATE, not just
|
||||
// CREATE. 001_initial_schema.js's autodate('generated_at', true) set
|
||||
// onCreate=true, onUpdate=false (the helper's default) — meaning every
|
||||
// subsequent battlecard_service.generate_battlecard() regeneration (an
|
||||
// upsert -> PATCH on the existing record, per
|
||||
// battlecard_repository.upsert()) left generated_at frozen at the
|
||||
// battlecard's FIRST-ever creation date, even though content/
|
||||
// content_json genuinely change underneath. BattlecardCard.vue
|
||||
// prominently shows "Updated {generated_at}", so a regenerated
|
||||
// battlecard with real fresh content looked like it hadn't been
|
||||
// touched in days — confirmed directly: a manual generate_battlecard()
|
||||
// call updated content/content_json but generated_at stayed at its
|
||||
// original timestamp from days earlier.
|
||||
//
|
||||
// In-place field mutation (getByName + reassign) rather than
|
||||
// remove+re-add — removing an existing field drops its underlying
|
||||
// column (destructive), which would wipe every existing battlecard's
|
||||
// real creation timestamp for no benefit; this only changes the
|
||||
// field's future stamping behavior.
|
||||
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId('battlecards');
|
||||
const field = collection.fields.getByName('generated_at');
|
||||
field.onUpdate = true;
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId('battlecards');
|
||||
const field = collection.fields.getByName('generated_at');
|
||||
field.onUpdate = false;
|
||||
return app.save(collection);
|
||||
});
|
||||
|
|
@ -1,25 +1,45 @@
|
|||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from repositories.product_repository import product_repository
|
||||
from repositories.competitor_repository import competitor_repository
|
||||
from repositories.battlecard_repository import battlecard_repository
|
||||
from repositories.price_history_repository import price_history_repository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How many of a competitor's most-recently-scraped active products feed
|
||||
# a battlecard. Previously 10 — skewed the summary toward whatever
|
||||
# happened to be scraped most recently (e.g. this week's watchlist
|
||||
# picks) instead of a reasonably complete view of the tracked catalogue.
|
||||
# Adjustable here only, same convention as core/scraper.py's
|
||||
# CACHE_STALENESS_HOURS.
|
||||
BATTLECARD_PRODUCT_LIMIT = 50
|
||||
|
||||
# How far back price_history is considered "recent" for the battlecard's
|
||||
# recent-changes line.
|
||||
RECENT_CHANGES_WINDOW_DAYS = 30
|
||||
|
||||
BATTLECARD_PROMPT = '''You are a competitive intelligence analyst for an adventure travel operator.
|
||||
Based on this competitor data, write a concise battlecard.
|
||||
|
||||
Competitor: {name}
|
||||
Top trips: {top_trips}
|
||||
Number of tracked trips: {trip_count}
|
||||
Price range: {price_low} - {price_high} USD
|
||||
Avg duration: {avg_duration} days
|
||||
Positioning: {positioning_summary}
|
||||
Recent changes: {recent_changes}
|
||||
Service levels offered: {service_levels}
|
||||
Typical target audience: {target_audiences}
|
||||
Common activities/themes: {activities_summary}
|
||||
Recent price changes (last 30 days): {recent_changes}
|
||||
|
||||
Write:
|
||||
1. One line positioning summary (max 15 words)
|
||||
2. Three strengths (bullet points, max 8 words each)
|
||||
3. Three weaknesses (bullet points, max 8 words each)
|
||||
4. One pricing observation (max 20 words)
|
||||
Write — ground every point in the specific data above (real prices,
|
||||
service levels, activities, audience, recent price moves) rather than
|
||||
generic statements that could apply to any competitor:
|
||||
1. One line positioning summary (max 20 words)
|
||||
2. Three strengths (bullet points, max 14 words each)
|
||||
3. Three weaknesses (bullet points, max 14 words each)
|
||||
4. One pricing observation (max 30 words) — reference the actual price
|
||||
range and/or a real recent price change when one is available
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{{
|
||||
|
|
@ -30,28 +50,106 @@ Return ONLY valid JSON:
|
|||
}}'''
|
||||
|
||||
|
||||
def _top_values(values, limit=3):
|
||||
"""
|
||||
Dedupes a list of possibly-null strings, preserving first-seen
|
||||
order, and joins the top `limit` into one comma-separated string.
|
||||
Used to summarise service_level/target_audience/activities across a
|
||||
competitor's tracked products without just picking the single most
|
||||
recent one.
|
||||
"""
|
||||
seen = []
|
||||
for value in values:
|
||||
if value and value not in seen:
|
||||
seen.append(value)
|
||||
return ', '.join(seen[:limit])
|
||||
|
||||
|
||||
def _summarise_products(products):
|
||||
"""Reduces a list of product records into the plain values BATTLECARD_PROMPT needs."""
|
||||
top_trips = ', '.join(p.get('trip_name', '') for p in products[:5] if p.get('trip_name'))
|
||||
"""
|
||||
Reduces a competitor's tracked products into the plain values
|
||||
BATTLECARD_PROMPT needs. Previously only derived 3 numbers
|
||||
(price range, avg duration) plus a joined trip-name list — ignored
|
||||
service_level/target_audience/activities even though every products
|
||||
row already has them (CLAUDE.md §12).
|
||||
"""
|
||||
top_trips = ', '.join(p.get('trip_name', '') for p in products[:8] if p.get('trip_name'))
|
||||
prices = [p['majority_price_usd'] for p in products if p.get('majority_price_usd')]
|
||||
durations = [p['duration_days'] for p in products if p.get('duration_days')]
|
||||
return {
|
||||
'top_trips': top_trips or 'No products on record',
|
||||
'trip_count': len(products),
|
||||
'price_low': min(prices) if prices else 'unknown',
|
||||
'price_high': max(prices) if prices else 'unknown',
|
||||
'avg_duration': round(sum(durations) / len(durations)) if durations else 'unknown',
|
||||
'service_levels': _top_values([p.get('service_level') for p in products]) or 'unknown',
|
||||
'target_audiences': _top_values([p.get('target_audience') for p in products]) or 'unknown',
|
||||
'activities_summary': _top_values([p.get('activities') for p in products], limit=5) or 'unknown',
|
||||
}
|
||||
|
||||
|
||||
def _summarise_recent_price_changes(products, days=RECENT_CHANGES_WINDOW_DAYS):
|
||||
"""
|
||||
Replaces the old hardcoded 'See price_history for this competitor.'
|
||||
placeholder with a real summary of that competitor's actual recent
|
||||
price moves — the model was previously being told to reference data
|
||||
it was never actually given.
|
||||
|
||||
Every price_history row already represents a genuine change
|
||||
(analysis_service._save_scrape_result() only writes one when
|
||||
old_price != new_price), so no extra magnitude filter is needed here
|
||||
— just a recency window. price_history has no competitor_id column
|
||||
(only product_id/tenant_id per CLAUDE.md §12), so this reuses the
|
||||
existing price_history_repository.list_for_product() per already-
|
||||
fetched product rather than adding a new repository method.
|
||||
|
||||
Data flow:
|
||||
products → per product: price_history_repository.list_for_product(limit=3) →
|
||||
entries within `days` with a real change_percent →
|
||||
sorted by |change_percent| descending (most notable first) →
|
||||
top 5 joined into one line, or a "no changes" fallback if none qualify
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
changes = []
|
||||
for product in products:
|
||||
entries = price_history_repository.list_for_product(product['id'], limit=3)
|
||||
for entry in entries:
|
||||
scraped_at = entry.get('scraped_at')
|
||||
change_percent = entry.get('change_percent')
|
||||
if not scraped_at or change_percent is None:
|
||||
continue
|
||||
try:
|
||||
scraped_dt = datetime.fromisoformat(scraped_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if scraped_dt < cutoff:
|
||||
continue
|
||||
direction = 'dropped' if change_percent < 0 else 'rose'
|
||||
price = entry.get('majority_price_usd')
|
||||
price_part = f' to ${price}' if price is not None else ''
|
||||
changes.append((
|
||||
abs(change_percent),
|
||||
f"{product.get('trip_name') or 'a trip'} {direction} {abs(change_percent)}%{price_part}",
|
||||
))
|
||||
|
||||
if not changes:
|
||||
return f'No significant price changes in the last {days} days.'
|
||||
|
||||
changes.sort(key=lambda c: c[0], reverse=True)
|
||||
return '; '.join(text for _, text in changes[:5])
|
||||
|
||||
|
||||
def generate_battlecard(competitor_id, tenant_id):
|
||||
"""
|
||||
Pulls latest product data for a competitor from PocketBase, passes
|
||||
structured data to the OpenRouter extraction model, and saves the
|
||||
generated battlecard text/JSON back to PocketBase.
|
||||
Pulls a competitor's tracked products (and their real recent price
|
||||
history) from PocketBase, passes a real structured summary to the
|
||||
OpenRouter extraction model, and saves the generated battlecard
|
||||
text/JSON back to PocketBase.
|
||||
|
||||
Data flow:
|
||||
competitor_id → PocketBase products (latest 10) →
|
||||
structured prompt → OpenRouter (claude-haiku via core.extractor) →
|
||||
competitor_id → PocketBase products (latest BATTLECARD_PRODUCT_LIMIT) →
|
||||
_summarise_products() + _summarise_recent_price_changes() →
|
||||
structured prompt → OpenRouter (via core.extractor) →
|
||||
battlecard text + JSON → PocketBase battlecards table →
|
||||
battlecard dict returned for immediate use
|
||||
"""
|
||||
|
|
@ -61,17 +159,21 @@ def generate_battlecard(competitor_id, tenant_id):
|
|||
from core.extractor import extract_with_openrouter
|
||||
|
||||
competitor = competitor_repository.get_by_id(competitor_id)
|
||||
products = product_repository.get_latest_for_competitor(competitor_id, limit=10)
|
||||
products = product_repository.get_latest_for_competitor(competitor_id, limit=BATTLECARD_PRODUCT_LIMIT)
|
||||
summary = _summarise_products(products)
|
||||
recent_changes = _summarise_recent_price_changes(products)
|
||||
|
||||
prompt = BATTLECARD_PROMPT.format(
|
||||
name=competitor.get('name', 'Unknown') if competitor else 'Unknown',
|
||||
top_trips=summary['top_trips'],
|
||||
trip_count=summary['trip_count'],
|
||||
price_low=summary['price_low'],
|
||||
price_high=summary['price_high'],
|
||||
avg_duration=summary['avg_duration'],
|
||||
positioning_summary='Derived from current product catalogue.',
|
||||
recent_changes='See price_history for this competitor.',
|
||||
service_levels=summary['service_levels'],
|
||||
target_audiences=summary['target_audiences'],
|
||||
activities_summary=summary['activities_summary'],
|
||||
recent_changes=recent_changes,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import importlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from services import battlecard_service
|
||||
from repositories.tenant_repository import tenant_repository
|
||||
from repositories.competitor_repository import competitor_repository
|
||||
from repositories.product_repository import product_repository
|
||||
from repositories.battlecard_repository import battlecard_repository
|
||||
from repositories.price_history_repository import price_history_repository
|
||||
|
||||
|
||||
def _install_fake_extractor(monkeypatch, fn):
|
||||
|
|
@ -74,3 +76,132 @@ def test_generate_battlecard_handles_no_products_gracefully(monkeypatch):
|
|||
|
||||
card = battlecard_service.generate_battlecard(competitor['id'], tenant['id'])
|
||||
assert card is not None
|
||||
|
||||
|
||||
# ── _top_values ─────────────────────────────────────────────────────
|
||||
|
||||
def test_top_values_dedupes_and_preserves_first_seen_order():
|
||||
result = battlecard_service._top_values(['Mid-range', 'Budget', 'Mid-range', 'Premium', 'Budget'])
|
||||
assert result == 'Mid-range, Budget, Premium'
|
||||
|
||||
|
||||
def test_top_values_skips_null_and_empty_entries():
|
||||
result = battlecard_service._top_values([None, 'Families', '', None, 'Couples'])
|
||||
assert result == 'Families, Couples'
|
||||
|
||||
|
||||
def test_top_values_respects_limit():
|
||||
result = battlecard_service._top_values(['A', 'B', 'C', 'D'], limit=2)
|
||||
assert result == 'A, B'
|
||||
|
||||
|
||||
def test_top_values_empty_input_returns_empty_string():
|
||||
assert battlecard_service._top_values([]) == ''
|
||||
|
||||
|
||||
# ── _summarise_products (richer fields) ────────────────────────────
|
||||
|
||||
def test_summarise_products_includes_trip_count_and_new_fields():
|
||||
products = [
|
||||
{'trip_name': 'Peru Classic', 'majority_price_usd': 2190, 'duration_days': 15,
|
||||
'service_level': 'Mid-range', 'target_audience': 'Couples', 'activities': 'Trekking'},
|
||||
{'trip_name': 'Peru Premium', 'majority_price_usd': 3200, 'duration_days': 15,
|
||||
'service_level': 'Premium', 'target_audience': 'Couples', 'activities': 'Trekking, Rafting'},
|
||||
]
|
||||
summary = battlecard_service._summarise_products(products)
|
||||
|
||||
assert summary['trip_count'] == 2
|
||||
assert summary['service_levels'] == 'Mid-range, Premium'
|
||||
assert summary['target_audiences'] == 'Couples'
|
||||
assert 'Trekking' in summary['activities_summary']
|
||||
|
||||
|
||||
def test_summarise_products_new_fields_default_to_unknown_when_missing():
|
||||
products = [{'trip_name': 'Peru Classic'}]
|
||||
summary = battlecard_service._summarise_products(products)
|
||||
|
||||
assert summary['service_levels'] == 'unknown'
|
||||
assert summary['target_audiences'] == 'unknown'
|
||||
assert summary['activities_summary'] == 'unknown'
|
||||
|
||||
|
||||
# ── _summarise_recent_price_changes ────────────────────────────────
|
||||
|
||||
def _iso_days_ago(days):
|
||||
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def test_summarise_recent_price_changes_includes_change_within_window():
|
||||
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
|
||||
price_history_repository.create({
|
||||
'tenant_id': 't1', 'product_id': product['id'], 'majority_price_usd': 1990,
|
||||
'change_percent': -12.0, 'scraped_at': _iso_days_ago(5),
|
||||
})
|
||||
|
||||
result = battlecard_service._summarise_recent_price_changes([product])
|
||||
|
||||
assert 'Peru Classic dropped 12.0%' in result
|
||||
assert '$1990' in result
|
||||
|
||||
|
||||
def test_summarise_recent_price_changes_ignores_entries_outside_window():
|
||||
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
|
||||
price_history_repository.create({
|
||||
'tenant_id': 't1', 'product_id': product['id'], 'majority_price_usd': 1990,
|
||||
'change_percent': -12.0, 'scraped_at': _iso_days_ago(45),
|
||||
})
|
||||
|
||||
result = battlecard_service._summarise_recent_price_changes([product])
|
||||
|
||||
assert result == 'No significant price changes in the last 30 days.'
|
||||
|
||||
|
||||
def test_summarise_recent_price_changes_sorts_by_magnitude_descending():
|
||||
product_a = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Small Move'})
|
||||
product_b = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Big Move'})
|
||||
price_history_repository.create({
|
||||
'tenant_id': 't1', 'product_id': product_a['id'], 'majority_price_usd': 1000,
|
||||
'change_percent': 2.0, 'scraped_at': _iso_days_ago(1),
|
||||
})
|
||||
price_history_repository.create({
|
||||
'tenant_id': 't1', 'product_id': product_b['id'], 'majority_price_usd': 2000,
|
||||
'change_percent': -25.0, 'scraped_at': _iso_days_ago(2),
|
||||
})
|
||||
|
||||
result = battlecard_service._summarise_recent_price_changes([product_a, product_b])
|
||||
|
||||
assert result.index('Big Move') < result.index('Small Move')
|
||||
|
||||
|
||||
def test_summarise_recent_price_changes_falls_back_when_no_price_history_at_all():
|
||||
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
|
||||
|
||||
result = battlecard_service._summarise_recent_price_changes([product])
|
||||
|
||||
assert result == 'No significant price changes in the last 30 days.'
|
||||
|
||||
|
||||
# ── generate_battlecard() end-to-end prompt content ────────────────
|
||||
|
||||
def test_generate_battlecard_prompt_uses_real_data_not_placeholders(monkeypatch):
|
||||
tenant, competitor = _setup()
|
||||
product = product_repository.get_latest_for_competitor(competitor['id'])[0]
|
||||
product_repository.update(product['id'], {'service_level': 'Premium', 'target_audience': 'Couples'})
|
||||
price_history_repository.create({
|
||||
'tenant_id': tenant['id'], 'product_id': product['id'], 'majority_price_usd': 1990,
|
||||
'change_percent': -9.1, 'scraped_at': _iso_days_ago(2),
|
||||
})
|
||||
|
||||
captured = {}
|
||||
_install_fake_extractor(monkeypatch, lambda prompt, content: captured.update(prompt=prompt) or {
|
||||
'positioning': 'x', 'strengths': [], 'weaknesses': [], 'pricing_observation': 'x',
|
||||
})
|
||||
|
||||
battlecard_service.generate_battlecard(competitor['id'], tenant['id'])
|
||||
|
||||
assert 'Premium' in captured['prompt']
|
||||
assert 'Couples' in captured['prompt']
|
||||
assert 'dropped 9.1%' in captured['prompt']
|
||||
assert 'See price_history for this competitor.' not in captured['prompt']
|
||||
assert 'Derived from current product catalogue.' not in captured['prompt']
|
||||
assert 'Positioning:' not in captured['prompt'] # positioning_summary input field removed entirely
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,6 +12,7 @@
|
|||
"@sentry/vue": "^7.120.3",
|
||||
"axios": "^1.7.9",
|
||||
"date-fns": "^3.6.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pinia": "^2.2.6",
|
||||
"pocketbase": "^0.25.1",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,53 @@ async function copyRows() {
|
|||
// real pullLatestResults() success from inside the Sheet itself.
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
const downloading = ref(false)
|
||||
|
||||
// Same transposed shape and same raw (unformatted) values as copyRows()
|
||||
// — one row per field, one column per competitor — so the two export
|
||||
// paths never diverge. ExcelJS (not the more common SheetJS/`xlsx`
|
||||
// package) specifically to avoid a known, currently-unpatched high-
|
||||
// severity prototype-pollution/ReDoS advisory in `xlsx` with no fix
|
||||
// available (checked via `npm audit` before choosing).
|
||||
//
|
||||
// Dynamically imported — a static top-level import bundled all of
|
||||
// ExcelJS (~940KB) into AnalysePage's main chunk, loaded on every visit
|
||||
// to the page whether or not the PM ever clicks this button. This way
|
||||
// it's only fetched the first time it's actually needed.
|
||||
async function downloadExcel() {
|
||||
downloading.value = true
|
||||
try {
|
||||
const { default: ExcelJS } = await import('exceljs')
|
||||
const rows = getRowsForTabType(props.tabType)
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const worksheet = workbook.addWorksheet('Bettersight Analysis')
|
||||
|
||||
worksheet.addRow(['Field', ...props.results.map((item) => item.name)])
|
||||
rows.forEach((row) => {
|
||||
worksheet.addRow([
|
||||
formatLabel(row.label, props.companyName),
|
||||
...props.results.map((item) => rawValue(row, item) ?? ''),
|
||||
])
|
||||
})
|
||||
|
||||
worksheet.getRow(1).font = { bold: true }
|
||||
worksheet.columns.forEach((col) => { col.width = 24 })
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer()
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `bettersight-analysis-${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -49,6 +96,9 @@ async function copyRows() {
|
|||
<UButton color="primary" variant="outline" icon="i-lucide-clipboard-copy" @click="copyRows">
|
||||
{{ copied ? 'Copied ✓' : 'Copy rows' }}
|
||||
</UButton>
|
||||
<UButton color="neutral" variant="outline" icon="i-lucide-download" :loading="downloading" @click="downloadExcel">
|
||||
Download Excel
|
||||
</UButton>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--t3);margin:0;line-height:1.5;">
|
||||
Your results are saved. To pull them directly into your Sheet: open your
|
||||
|
|
|
|||
|
|
@ -29,5 +29,16 @@ function timeAgo(ts) {
|
|||
class="pill danger"
|
||||
>{{ weakness }}</span>
|
||||
</div>
|
||||
<div v-if="battlecard.content_json?.pricing_observation" class="bc-pricing">
|
||||
💰 {{ battlecard.content_json.pricing_observation }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bc-pricing {
|
||||
margin-top: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--t2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
# Bettersight — Tier Feature List
|
||||
|
||||
## Analyse — $349/month (MVP tier)
|
||||
|
||||
- **Dashboard** — primary interface for running analysis and viewing results (no Sheet-side analysis UI)
|
||||
- **Trip-intent matching** — PM describes destination/duration/style, Firecrawl `/map` finds competitor trip URLs on demand, with a confirm/swap screen before any scrape runs
|
||||
- **On-demand competitor analysis** — side-by-side dashboard results, with fast path (serve from PocketBase) vs refresh path (live scrape) depending on freshness
|
||||
- **Pull results into Google Sheets** — standalone Apps Script sidebar (installed once, works in any workbook the PM opens): click "Pull latest results" inside the Sheet itself to fetch the PM's most recent completed analysis from Flask and write it into the selected tab, with header-based column detection so it survives the PM renaming/reordering columns; Sheet-initiated, since a stateless backend has no way to reach into whichever Sheet is currently open — a distinct feature from the "Copy rows" clipboard fallback
|
||||
- **Per-seat trip watchlist** — each seat can pin 1 specific trip (from a completed analysis) to their own personal watchlist slot; the nightly cron re-checks exactly those pinned trips instead of scraping every competitor's homepage, so nightly volume is small and predictable (seats × 1) and every resulting alert is about a trip someone explicitly chose to track, not homepage noise
|
||||
- **Auto-generated battlecards** — regenerated after each scrape that produces genuinely fresh data
|
||||
- **In-dashboard notifications** — activity feed for price/product changes (including watchlist-driven nightly checks), plus the weekly Monday HTML email brief via Resend
|
||||
|
||||
Not in MVP: Discover/Intelligence/Enterprise tier features (scheduled catalogue crawl, OSINT signal pipeline, custom fields, Slack/Teams/WhatsApp). Comparable trip discovery (semantic similarity) is a Phase 2 add-on *within* Analyse.
|
||||
|
||||
---
|
||||
|
||||
## Discover — $499/month
|
||||
|
||||
Everything in Analyse, plus proactive surveillance:
|
||||
|
||||
- **Bigger per-seat watchlist** — cap raised from 1 to 2 pinned trips per seat, layered on top of (not instead of) auto-discovery below
|
||||
- **Full catalogue auto-discovery** — Firecrawl `/map` runs weekly per competitor (not just on-demand), Sunday night via n8n — finds trip pages a PM never knew to look for or pin, closing the gap the watchlist alone can't (you can only watch a trip you've already found)
|
||||
- **New trip detection** — flags competitor products added since the last crawl, auto-queued for scraping without PM involvement
|
||||
- **Catalogue completeness** — trip counts per competitor per destination
|
||||
- **Destination gap analysis** — destinations competitors cover that the client doesn't
|
||||
- **Monday brief upgrade** — now includes all auto-discovered new products, not just watchlisted ones
|
||||
- **External notifications** — PM-connected Slack webhook, Microsoft Teams webhook, WhatsApp via Twilio
|
||||
|
||||
Pitch: *"Tell me everything that changed this week that I didn't know to ask about"* — system runs autonomously, catches unknown unknowns.
|
||||
|
||||
---
|
||||
|
||||
## Intelligence — $999/month
|
||||
|
||||
Everything in Discover, plus predictive signals:
|
||||
|
||||
- **Largest per-seat watchlist** — cap raised to 3 pinned trips per seat, on top of full auto-discovery (same as Discover) — the watchlist becomes "pin your top priorities to double-check nightly," not the primary discovery mechanism
|
||||
- **Full OSINT signal pipeline** — 15+ sources (job postings, headcount, funding, SEO/PPC, domain registrations, reviews, news, app store updates, etc.)
|
||||
- **Competitor Trajectory Score** — weighted weekly signal aggregation, mapped to expansion/holding/contracting labels
|
||||
- **Wayback Machine positioning history** — tracks homepage/pricing messaging changes over months via the CDX API
|
||||
- **Network mapping** — leadership, board, and investor connections; cross-competitor overlap detection
|
||||
- **Monthly narrative report** — AI-generated predicted moves with confidence ratings
|
||||
- **Early warning alerts** — Gotify-fired on significant signal clusters
|
||||
|
||||
Pitch: *"Tell me what my competitors are about to do"* — 3–6 month predictive forecasting, not just detection.
|
||||
|
||||
---
|
||||
|
||||
## Enterprise — $1,499/month (unlisted)
|
||||
|
||||
Everything in Intelligence, plus: dedicated onboarding, custom signal sources, quarterly strategy briefing call, SLA on scrape freshness/alerts, custom branded PDF reports. Per-seat watchlist cap: 3 (same as Intelligence — not a differentiator at this tier).
|
||||
|
||||
---
|
||||
|
||||
**Build order note:** per CLAUDE.md §27, none of Discover/Intelligence/Enterprise gets built until Analyse has paying clients — this is spec-ahead-of-build. The per-seat watchlist caps for Discover/Intelligence/Enterprise (2/3/3) are placeholders, easy to revisit before those tiers actually ship.
|
||||
Loading…
Reference in New Issue