Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Local-AEO signals. The OAuth connection reuses `google_connections` with `connec
| **gbp_keyword_impressions** | Search-keyword impressions over the trailing synced window (one aggregate per keyword; `period_start`/`period_end` are YYYY-MM). Range-replaced each sync. Unique: `(projectId, locationName, periodEnd, keyword)` |
| **gbp_keyword_monthly** | Per-month keyword impressions series — **accumulates** across syncs (recent complete months upserted, older in-retention months preserved) so intelligence can detect month-over-month keyword drops. Unique: `(projectId, locationName, month, keyword)` |
| **gbp_place_actions** | Booking / reservation / order CTAs per location (`provider_type` MERCHANT = direct, AGGREGATOR = OTA). Range-replaced each sync. |
| **gbp_lodging_snapshots** | Hotel structured attributes, snapshot-on-change. `populated_group_count = 0` is an AEO gap. |
| **gbp_lodging_snapshots** | Hotel structured attributes, snapshot-on-change. `populated_group_count = 0` means the Lodging API returns no structured attributes; common even for complete hotels (the owner-set "Hotel details" amenity panel is a separate surface the API does not expose), so it is a verify signal, not a confirmed gap. |
| **gbp_place_details** | Places (New) rendered-listing snapshots (amenities, accessibility, editorial summary) for lodging locations, fetched via the Places API key and snapshot-on-changed. `tier` records the field-mask SKU. Cross-referenced against the lodging profile for the `gbp-listing-discrepancy` insight (#648). |

### Integrations — OpenAI Ads (ChatGPT ads)
Expand Down
2 changes: 1 addition & 1 deletion packages/api-routes/test/gbp-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe('summarizePlaceActions', () => {
describe('summarizeLodging', () => {
it('splits lodging locations into populated vs empty', () => {
const out = summarizeLodging([
{ locationName: 'locations/1', populatedGroupCount: 0 }, // empty hotel profile (AEO gap)
{ locationName: 'locations/1', populatedGroupCount: 0 }, // Lodging API returns no structured attributes (verify Hotel details)
{ locationName: 'locations/2', populatedGroupCount: 5 },
])
expect(out).toEqual({ lodgingLocationCount: 2, populatedLodgingCount: 1, emptyLodgingCount: 1 })
Expand Down
2 changes: 1 addition & 1 deletion packages/canonry/src/commands/gbp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export async function gbpLodging(
}
console.log(`${response.total} lodging profile(s):`)
for (const l of response.lodging) {
const note = l.populatedGroupCount === 0 ? ' — EMPTY (AEO gap: no structured amenities for AI engines to cite)' : ''
const note = l.populatedGroupCount === 0 ? ' (none readable via the Lodging API; verify the "Hotel details" panel, often set there but not exposed by the API)' : ''
console.log(` ${l.locationName} ${l.populatedGroupCount} attribute group(s)${note}`)
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/canonry/src/mcp/tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,7 +1197,7 @@ export const canonryMcpTools = [
defineTool({
name: 'canonry_gbp_lodging',
title: 'Get GBP lodging attributes',
description: 'List the latest Google Business Profile lodging snapshot per location (hotel structured attributes). populatedGroupCount=0 means an empty profile — an AEO gap.',
description: 'List the latest Google Business Profile lodging snapshot per location (hotel structured attributes). populatedGroupCount=0 means the Lodging API returned no structured attributes, which is common even for complete hotels because the owner-set "Hotel details" amenity panel is a separate surface the API does not expose. Treat it as a "verify the Hotel details panel", not a confirmed gap.',
access: 'read',
tier: 'gbp',
inputSchema: gbpLocationScopedInputSchema,
Expand Down
6 changes: 3 additions & 3 deletions packages/canonry/test/intelligence-service-gbp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ describe('IntelligenceService.analyzeAndPersistGbp', () => {
expect(i.id.startsWith('proj_gbp::gbp::locations/A::')).toBe(true)
}

// Severities: lodging high, cta medium, metric -80% high, keyword -70% high.
// Severities: lodging low (verify-nudge), cta medium, metric -80% high, keyword -70% high.
const sev = Object.fromEntries(result.map((i) => [i.type, i.severity]))
expect(sev['gbp-lodging-gap']).toBe('high')
expect(sev['gbp-lodging-gap']).toBe('low')
expect(sev['gbp-cta-gap']).toBe('medium')
expect(sev['gbp-metric-drop']).toBe('high')
expect(sev['gbp-keyword-drop']).toBe('high')
Expand Down Expand Up @@ -144,7 +144,7 @@ describe('IntelligenceService.analyzeAndPersistGbp', () => {
expect(types).toContain('gbp-listing-discrepancy')
expect(types).not.toContain('gbp-lodging-gap')
const disc = result.find((i) => i.type === 'gbp-listing-discrepancy')!
expect(disc.severity).toBe('high')
expect(disc.severity).toBe('medium')
expect(disc.query).toBe('Gjelina Venice')
// The reason names the specific amenities extracted from the Places snapshot.
expect(disc.recommendation?.reason).toContain('breakfast')
Expand Down
22 changes: 11 additions & 11 deletions packages/canonry/test/run-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ describe('RunCoordinator', () => {
id: projectId, name: 'gbp-coord', displayName: 'GBP', canonicalDomain: 'example.com',
country: 'US', language: 'en', createdAt: now, updatedAt: now,
}).run()
// One selected location with an empty lodging profile → one high lodging-gap insight.
// One selected location with an unreadable lodging profile → one low-severity
// lodging-gap verify-nudge (an empty Lodging API result is not a confirmed gap).
db.insert(gbpLocations).values({
id: 'l1', projectId, accountName: 'accounts/1', locationName: 'locations/1',
displayName: 'Loc 1', selected: true, syncedAt: now, createdAt: now, updatedAt: now,
Expand Down Expand Up @@ -343,22 +344,21 @@ describe('RunCoordinator', () => {
expect(avSpy).not.toHaveBeenCalled()
expect(db.select().from(healthSnapshots).all()).toHaveLength(0)

// The insight was persisted and the notifier fired.
// The insight was persisted and the run notifier fired.
expect(db.select().from(insights).where(eq(insights.runId, runId)).all()).toHaveLength(1)
expect(notifier.onRunCompleted).toHaveBeenCalledWith(runId, projectId)
expect(insightSpy).toHaveBeenCalledTimes(1)
expect(insightSpy.mock.calls[0]![0]).toBe(runId)
expect(insightSpy.mock.calls[0]![1]).toBe(projectId)
expect(insightSpy.mock.calls[0]![2].insights).toMatchObject([
{ type: 'gbp-lodging-gap', severity: 'high', provider: 'gbp' },
])

// Aero woke with the GBP insight count.
// The only insight is a low-severity lodging verify-nudge, so the
// critical/high insight-notification callback does NOT fire (it gates on
// criticalOrHigh > 0). The insight is still persisted and surfaces in Aero's count.
expect(insightSpy).not.toHaveBeenCalled()

// Aero woke with the GBP insight count. The lodging-gap is a low-severity
// verify-nudge, so it counts toward insightCount but NOT criticalOrHigh.
expect(captured).toBeDefined()
if (!captured || captured.kind === 'aeo-discover-probe') throw new Error('expected a gbp-sync aero context')
expect(captured.kind).toBe('gbp-sync')
expect(captured.insightCount).toBe(1)
expect(captured.criticalOrHigh).toBe(1)
expect(captured.criticalOrHigh).toBe(0)
})

it('discovery aero context resolves the session by runId, not by "latest non-queued"', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ export async function getLodging(
/**
* Count non-empty top-level attribute groups in a Lodging resource, excluding
* the bookkeeping fields `name` and `metadata`. An empty object/array/null is
* not "populated". Zero means the hotel has no structured amenities set — an
* AEO gap, not an error.
* not "populated". Zero means the Lodging API returned no structured
* attributes, which is common even for complete hotels: the owner-set
* "Hotel details" amenity panel is a separate surface this API does not
* expose. So zero is a "verify the Hotel details panel", not proof the hotel
* has no amenities.
*/
export function countPopulatedGroups(lodging: GbpLodging): number {
let count = 0
Expand Down
37 changes: 21 additions & 16 deletions packages/intelligence/src/gbp-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,36 +106,41 @@ export function analyzeGbp(signals: GbpLocationSignals[]): GbpInsightDraft[] {
for (const loc of signals) {
const base = { locationName: loc.locationName, query: loc.displayName, provider: GBP_INSIGHT_PROVIDER }

// 1. Lodging profile empty — the GBP API exposes only owner-configured
// attributes, so an empty profile is the operator's blind spot. Google's
// rendered listing may still synthesize amenities from Hotel Center, OTAs,
// and Places/user data, but that synthesized data is NOT what the
// structured profile (the source AI answer engines read) exposes.
// 1. canonry can't read structured Lodging attributes for this hotel.
// `getLodging` returns the GBP Lodging resource, which is empty by
// default even for well-managed hotels: the owner-facing "Hotel details"
// amenity panel (breakfast / wifi / parking / pets / accessibility)
// writes to a separate attribute surface the Lodging API does not return.
// So `populatedGroupCount === 0` means "canonry can't confirm structured
// attributes via the API", NOT "the owner set no amenities". These are
// verify-nudges, not confirmed gaps — framed and severitied accordingly.
if (loc.lodgingCapable && loc.lodgingEmpty) {
if (loc.placesAmenities.length > 0) {
// Evidence-backed (#648 Phase B): the Places API confirms the public
// listing shows specific amenities the empty GBP profile exposes none
// of. This supersedes the generic lodging-gap with concrete proof.
// The Places API independently shows amenities while the Lodging API
// returns nothing. Worth a look, but it does NOT prove the owner left
// them unset: Places can read amenities the Lodging API can't, so the
// likeliest cause is the "Hotel details" panel is filled and simply not
// exposed via the Lodging API. Surface it as a verify, not a defect.
const amenityList = formatAmenityList(loc.placesAmenities)
drafts.push({
...base,
type: 'gbp-listing-discrepancy',
severity: 'high',
title: `${loc.displayName}: public listing shows ${loc.placesAmenities.length} amenit${loc.placesAmenities.length === 1 ? 'y' : 'ies'} your GBP profile doesn’t`,
severity: 'medium',
title: `${loc.displayName}: public listing advertises ${loc.placesAmenities.length} amenit${loc.placesAmenities.length === 1 ? 'y' : 'ies'} canonry can’t confirm via the GBP API`,
recommendation: {
action: 'Populate the hotel’s structured amenity attributes in Google Business Profile to match what its public listing already advertises — the amenity source you directly control',
reason: `Google’s rendered listing advertises ${amenityList} (synthesized from Hotel Center / OTAs / Places), but your GBP structured profile has zero populated attributes. The structured attributes are what AI answer engines cite and the only amenity data you control, so the public listing is making promises your profile can’t back.`,
action: 'Verify these amenities are set in the Google Business Profile "Hotel details" panel so the structured profile matches what the public listing advertises.',
reason: `Google’s rendered listing advertises ${amenityList}, but the GBP Lodging API returns no structured attributes for this location. The Lodging API does not expose the owner-set "Hotel details" amenity panel, so the amenities may already be set there and simply not be readable via the API. Verify in Hotel details: if any are missing, add them, since the structured attributes are the amenity data you directly control and that AI answer engines cite.`,
},
})
} else {
drafts.push({
...base,
type: 'gbp-lodging-gap',
severity: 'high',
title: `${loc.displayName}: lodging profile has no structured attributes`,
severity: 'low',
title: `${loc.displayName}: structured lodging attributes not readable via the GBP API`,
recommendation: {
action: 'Populate the hotel’s structured amenity attributes in Google Business Profile — the amenity source you directly control',
reason: 'The GBP API exposes only owner-configured attributes, and this profile has none. Google’s rendered listing may still show amenities it synthesizes from Hotel Center, OTAs, and user data — so the public listing can differ from this profile — but the structured attributes are what AI answer engines cite and the only amenity data you control.',
action: 'Verify the hotel amenities in the Google Business Profile "Hotel details" panel. If they are already set there, no change is needed.',
reason: 'The GBP Lodging API returns no structured attributes for this location. That resource is commonly empty even for complete hotels, because the owner-set "Hotel details" amenity panel (breakfast, wifi, parking, accessibility, and the like) writes to a separate surface the Lodging API does not expose. Treat this as a verify, not a confirmed gap: confirm the amenities are set in Hotel details, the amenity source you directly control and that AI answer engines cite.',
},
})
}
Expand Down
20 changes: 12 additions & 8 deletions packages/intelligence/test/gbp-analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,20 @@ describe('analyzeGbp', () => {
})

describe('lodging gap', () => {
it('flags a lodging-capable location with an empty profile (high)', () => {
it('flags an unreadable lodging profile as a low-severity verify (not a confirmed gap)', () => {
const insights = analyzeGbp([healthy({ lodgingCapable: true, lodgingEmpty: true })])
const lodging = insights.filter((i) => i.type === 'gbp-lodging-gap')
expect(lodging).toHaveLength(1)
expect(lodging[0]!.severity).toBe('high')
// An empty Lodging API result does NOT prove the owner set no amenities
// (the "Hotel details" panel is a separate surface the API can't read),
// so this is a verify-nudge, not a high-severity defect.
expect(lodging[0]!.severity).toBe('low')
expect(lodging[0]!.provider).toBe('gbp')
expect(lodging[0]!.query).toBe('Test Hotel')
expect(lodging[0]!.locationName).toBe('locations/1')
// Flags the API-vs-rendered-listing discrepancy: the API returns only
// owner-configured attributes, so the public listing may differ (#648).
expect(lodging[0]!.recommendation?.reason).toMatch(/owner-configured/)
expect(lodging[0]!.recommendation?.reason).toMatch(/rendered listing/)
// Frames it honestly: verify the "Hotel details" panel, not "you have none".
expect(lodging[0]!.recommendation?.reason).toMatch(/Hotel details/)
expect(lodging[0]!.recommendation?.reason).toMatch(/not a confirmed gap/)
})

it('does not flag a populated lodging profile', () => {
Expand All @@ -57,14 +59,16 @@ describe('analyzeGbp', () => {
})

describe('listing discrepancy (#648 Phase B)', () => {
it('fires gbp-listing-discrepancy (high) with Places amenities as evidence', () => {
it('fires gbp-listing-discrepancy (medium) with Places amenities as evidence', () => {
const insights = analyzeGbp([healthy({
lodgingCapable: true, lodgingEmpty: true,
placesAmenities: ['breakfast', 'parking', 'pet-friendly'],
})])
const disc = insights.filter((i) => i.type === 'gbp-listing-discrepancy')
expect(disc).toHaveLength(1)
expect(disc[0]!.severity).toBe('high')
// Places evidence makes it worth a look, but the Lodging API still can't
// read the "Hotel details" panel, so it's a verify (medium), not a high.
expect(disc[0]!.severity).toBe('medium')
expect(disc[0]!.provider).toBe('gbp')
// Title carries the count (plural), reason names the specific amenities.
expect(disc[0]!.title).toContain('3 amenities')
Expand Down
4 changes: 2 additions & 2 deletions skills/aero/references/regression-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ A `gbp-sync` run produces a separate family of **location-scoped** insights (`pr

| Type | Meaning | Response |
|---|---|---|
| `gbp-lodging-gap` (high) | Lodging-capable location with an empty structured-attribute profile, no Places evidence available | AI engines have no amenities to cite — recommend populating Lodging attributes (pool, wifi, pets, parking, …) in the Business Profile. Highest-signal local AEO fix for hotels. |
| `gbp-listing-discrepancy` (high) | Empty GBP profile **plus** a Places snapshot proving the public listing advertises specific amenities (#648) | The evidence-backed lodging gap — it names the exact amenities (breakfast, parking, pet-friendly, …) the public listing shows but the structured profile doesn't back. Quote them when recommending the fix; supersedes `gbp-lodging-gap`. Requires a Places API key (`gbp.places.api-key` doctor check). |
| `gbp-lodging-gap` (low) | Lodging-capable location whose structured Lodging attributes canonry can't read via the GBP API, no Places evidence available | A **verify-nudge, not a confirmed gap**. The GBP Lodging API returns empty even for complete hotels, because the owner-set "Hotel details" amenity panel writes to a separate surface the API doesn't expose. Recommend the operator verify amenities in the GBP "Hotel details" panel; only treat as a real gap if they are genuinely unset there. Do not tell them they "have no amenities". |
| `gbp-listing-discrepancy` (medium) | canonry can't read the Lodging attributes **plus** a Places snapshot showing the public listing advertises specific amenities (#648) | Worth a look but still a **verify**: Places can read amenities the Lodging API can't, so they are likely already set in "Hotel details" and just not API-readable. Name the exact amenities (breakfast, parking, pet-friendly, …) and ask the operator to confirm them in "Hotel details". Supersedes `gbp-lodging-gap`. Requires a Places API key (`gbp.places.api-key` doctor check). |
| `gbp-cta-gap` (medium) | Place actions present but only aggregator/OTA booking links | Recommend adding a direct (merchant-owned) booking/reservation link as the preferred place action so AI surfaces the property's own site, not an OTA. |
| `gbp-metric-drop` (high/medium) | A headline conversion metric (direction requests, website clicks, call clicks) fell sharply week-over-week | **First rule out the ~2 to 3 day reporting lag** before treating the drop as real: confirm the window is anchored to `freshness.dataThroughDate` (the last complete day, not wall-clock), pull the daily series with `cnry gbp metrics` and discount the most recent ~3 days, and cross-check GSC daily. The lag reads falsely negative right after US holidays. Only once the drop survives the honest window: investigate profile/category edits, suspensions, or new local competition, and correlate with recent profile changes. |
| `gbp-keyword-drop` (high/medium) | A head local search term's impressions fell month-over-month | Check whether the property still ranks for the term; refresh the profile / categories. Needs ≥2 accumulated months of `gbp_keyword_monthly` history. |
Expand Down
Loading
Loading