Skip to content
Open
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: 2 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@
"chat.feedback.tag.wrongDataset": "Wrong dataset",
"chat.feedback.thumbsDown": "Bad response",
"chat.feedback.thumbsUp": "Good response",
"chat.greeting.disclosure": "Orbit remembers what you've explored on this device to personalize this greeting — it's never sent to a server. Manage it in Tools → Privacy.",
"chat.greeting.disclosure.link": "View privacy",
"chat.header.subtitle": "· digital docent",
"chat.header.title": "Orbit",
"chat.input.label": "Ask Orbit",
Expand Down
50 changes: 48 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
import { updateMapControlsPosition } from './ui/mapControlsUI'
import { initToolsMenu, syncToolsMenuState, syncToolsMenuLayout, pulseBrowseButton } from './ui/toolsMenuUI'
import { openCreditsPanel } from './ui/creditsPanel'
import { initChatUI, openChat, openChatSettings, notifyDatasetChanged, showChatTrigger, hideChatTrigger, closeChat, flushPendingGlobeActions } from './ui/chatUI'
import { initChatUI, openChat, openChatSettings, notifyDatasetChanged, showChatTrigger, hideChatTrigger, closeChat, flushPendingGlobeActions, playReturningGreeting } from './ui/chatUI'
import { loadViewPreferences, saveViewPreferences, type ViewPreferences } from './utils/viewPreferences'
import { initHelpUI, setActiveDataset as setHelpActiveDataset } from './ui/helpUI'
import { showDisclosureBannerIfNeeded } from './ui/disclosureBanner'
Expand Down Expand Up @@ -64,7 +64,7 @@ import { showTourControls, hideTourControls, hideAllTourTextBoxes, hideAllTourIm
import { initLegendForDataset, clearLegendCache, loadConfig } from './services/docentService'
import { isMobile, IS_MOBILE_NATIVE, getCloudTextureUrl } from './utils/deviceCapability'
import { initDeepLinks } from './services/deepLinkService'
import { recordVisit, writeLastSession } from './services/visitMemory'
import { recordVisit, writeLastSession, getLastSession, getRecent } from './services/visitMemory'
import { getCatalogMode, setCatalogMode } from './utils/catalogMode'
import {
hideCatalogTabs,
Expand Down Expand Up @@ -624,6 +624,52 @@ class InteractiveSphere {
},
openChatWithQuery: (query) => this.openChatWithQuery(query),
})

// §9.3 — proactive returning-visitor greeting. Catalog mode only,
// and only when the last session ended > 24 h ago. Fire-and-forget;
// playReturningGreeting / triggerOpeningTurn self-gate on the LLM
// being configured and on once-per-session, so this is safe to call
// unconditionally here.
this.maybeGreetReturningVisitor()
}

/**
* §9.3 — decide whether to fire Orbit's returning-user greeting and,
* if so, hand the local visit context to the chat UI. All inputs are
* local-only (visit memory); nothing is sent server-side except the
* resulting prompt block to the configured LLM.
*/
private maybeGreetReturningVisitor(): void {
if (!getCatalogMode()) return
const last = getLastSession()
if (!last) return
const lastMs = Date.parse(last)
if (!Number.isFinite(lastMs)) return
const DAY_MS = 24 * 60 * 60 * 1000
const elapsed = Date.now() - lastMs
if (elapsed <= DAY_MS) return // need a gap of more than 24 h

const datasets = this.appState.datasets
const daysSince = Math.max(1, Math.floor(elapsed / DAY_MS))
// New since the last visit: catalog rows whose dateAdded is later
// than lastSession (cap 5 titles; let the LLM pick what to surface).
const newSinceTitles = datasets
.filter((d) => {
if (d.isHidden) return false
const added = d.enriched?.dateAdded
if (!added) return false
const addedMs = Date.parse(added)
return Number.isFinite(addedMs) && addedMs > lastMs
})
.slice(0, 5)
.map((d) => d.title)
// Recently viewed (cap 3), resolved to current catalog titles.
const byId = new Map(datasets.map((d) => [d.id, d]))
const recentTitles = getRecent(3)
.map((id) => byId.get(id)?.title)
.filter((title): title is string => !!title)

void playReturningGreeting({ daysSince, newSinceTitles, recentTitles })
}

/**
Expand Down
30 changes: 30 additions & 0 deletions src/services/docentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Dataset, ChatMessage } from '../types'
import {
buildCategorySummary,
buildCurrentDatasetContext,
buildReturningUserBlock,
buildSystemPrompt,
buildMessageHistory,
buildCompressedHistory,
Expand Down Expand Up @@ -171,12 +172,41 @@ describe('getListFeaturedDatasetsTool (Phase 1c)', () => {
})
})

describe('buildReturningUserBlock', () => {
it('includes the days-since line and the supplied titles', () => {
const block = buildReturningUserBlock({
daysSince: 3,
newSinceTitles: ['Alpha', 'Bravo'],
recentTitles: ['Charlie'],
})
expect(block).toContain('returning visitor')
expect(block).toContain('3 days ago')
expect(block).toContain('Alpha; Bravo')
expect(block).toContain('Charlie')
})

it('singularizes one day and omits empty new/recent lines', () => {
const block = buildReturningUserBlock({ daysSince: 1, newSinceTitles: [], recentTitles: [] })
expect(block).toContain('1 day ago')
expect(block).not.toContain('New datasets')
expect(block).not.toContain('Recently viewed')
})
})

describe('buildSystemPrompt', () => {
it('includes docent role description', () => {
const prompt = buildSystemPrompt(datasets, null)
expect(prompt).toContain('Orbit')
})

it('prepends the returning-user block only when provided', () => {
const without = buildSystemPrompt(datasets, null)
expect(without).not.toContain('returning visitor')
const block = buildReturningUserBlock({ daysSince: 2, newSinceTitles: [], recentTitles: [] })
const withBlock = buildSystemPrompt(datasets, null, 'general', false, null, null, null, undefined, block)
expect(withBlock).toContain('returning visitor')
})

it('places the language directive BEFORE the role description for non-English locales', async () => {
// Wave 5/6 testing showed mid-tier models (Llama-3.1-70B, etc.)
// ignored a respond-in-language directive appended at the end of
Expand Down
39 changes: 38 additions & 1 deletion src/services/docentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,42 @@ Respond at a professional/graduate science level. Use precise scientific termino
* `datasets` is still passed in for the (currently unused) categorySummary
* helper that other surfaces consume; the prompt itself ignores it.
*/
/** Context for a returning-visitor greeting (§9.3). All fields come
* from local visit memory — never sent server-side except as this
* prompt block to the configured LLM. */
export interface ReturningUserContext {
/** Whole days since the previous session end. */
daysSince: number
/** Titles of catalog datasets added since the last visit (capped). */
newSinceTitles: readonly string[]
/** Titles of datasets the user recently opened (capped). */
recentTitles: readonly string[]
}

/**
* Build the returning-user context block prepended to the turn-0
* system prompt when the §9.3 greeting trigger fires. Pure string
* builder — the caller decides whether to include it (cost discipline:
* don't compose it when the trigger doesn't fire). Lines with no data
* are omitted so the LLM isn't told "New datasets since: 0".
*/
export function buildReturningUserBlock(ctx: ReturningUserContext): string {
const lines: string[] = ['You are greeting a returning visitor. Context:']
lines.push(` Last visited: about ${ctx.daysSince} day${ctx.daysSince === 1 ? '' : 's'} ago`)
if (ctx.newSinceTitles.length > 0) {
lines.push(` New datasets since their last visit: ${ctx.newSinceTitles.length} (e.g. ${ctx.newSinceTitles.join('; ')})`)
}
if (ctx.recentTitles.length > 0) {
lines.push(` Recently viewed by this user: ${ctx.recentTitles.join('; ')}`)
}
lines.push(
'Open with one short, warm paragraph welcoming them back, then offer ONE or TWO concrete next steps (a new dataset to try, returning to something they viewed, or a guided tour). ' +
'Do not list everything — pick a highlight. ' +
'When you name a dataset, you MUST still confirm its id via a discovery tool (or the [RELEVANT DATASETS] block) before emitting a <<LOAD:...>> marker — the titles above are context, not valid ids.',
)
return lines.join('\n')
}

export function buildSystemPrompt(
_datasets: Dataset[],
currentDataset: Dataset | null,
Expand All @@ -144,6 +180,7 @@ export function buildSystemPrompt(
currentTime?: string | null,
qaContext?: string | null,
mapViewContext?: Parameters<typeof buildViewContextSection>[0],
returningUserBlock?: string | null,
): string {
const currentContext = buildCurrentDatasetContext(currentDataset, legendDescription, currentTime)
const languagePreface = buildLanguagePreface()
Expand All @@ -152,7 +189,7 @@ export function buildSystemPrompt(
return `${languagePreface}You are Orbit, a Digital Docent for Science on a Sphere — an interactive 3D globe that visualizes Earth science datasets from NOAA.

Your role is to be a warm, knowledgeable guide. You help visitors explore and understand environmental data by explaining what they're seeing and recommending relevant datasets to load onto the globe.

${returningUserBlock ? `\n${returningUserBlock}\n` : ''}
IMPORTANT: All datasets are GLOBAL — they cover the entire Earth, rendered on a 3D sphere. The user's current view only shows one side of the globe, but the data extends everywhere. Never say a dataset "only shows" one region or "doesn't cover" a location. The user can rotate the globe or use <<FLY:...>> to view any part of the world.

## STRICT RULES — FOLLOW EXACTLY
Expand Down
64 changes: 63 additions & 1 deletion src/services/docentService.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { Dataset, ChatMessage, DocentConfig } from '../types'
import { processMessage, loadConfig, saveConfig, getDefaultConfig, validateAndCleanText, captureViewContext, readCurrentTime, executeSearchDatasets, executeListFeaturedDatasets, clearPreSearchCache } from './docentService'
import { processMessage, triggerOpeningTurn, resetOpeningTurnForTests, loadConfig, saveConfig, getDefaultConfig, validateAndCleanText, captureViewContext, readCurrentTime, executeSearchDatasets, executeListFeaturedDatasets, clearPreSearchCache } from './docentService'
import { getDegradedReason, resetForTests as resetDegradedForTests } from './docentDegradedState'
import type { DocentStreamChunk } from './docentService'

Expand Down Expand Up @@ -2560,3 +2560,65 @@ describe('processMessage — backend tool round-trip', () => {
expect(toolMsg.content).toContain('Climate Reanalysis 2024')
})
})

describe('triggerOpeningTurn (§9.3 returning-user greeting)', () => {
const returning = {
daysSince: 3,
newSinceTitles: ['Wildfire Tracker'],
recentTitles: ['Sea Surface Temperature'],
}
const enabledConfig: DocentConfig = {
apiUrl: 'http://localhost:11434/v1',
apiKey: '',
model: 'test',
enabled: true,
readingLevel: 'general',
visionEnabled: false,
}

beforeEach(() => resetOpeningTurnForTests())

it('yields nothing when the LLM is unconfigured', async () => {
const chunks: DocentStreamChunk[] = []
for await (const c of triggerOpeningTurn({ datasets, returning, config: disabledConfig })) {
chunks.push(c)
}
expect(chunks).toEqual([])
})

it('streams a greeting and refuses to re-fire within the session', async () => {
const { streamChat } = await import('./llmProvider')
vi.mocked(streamChat).mockImplementation(async function* () {
yield { type: 'delta' as const, text: 'Welcome back!' }
yield { type: 'done' as const }
})

const first: DocentStreamChunk[] = []
for await (const c of triggerOpeningTurn({ datasets, returning, config: enabledConfig })) {
first.push(c)
}
const text = first.filter(c => c.type === 'delta').map(c => (c as { text: string }).text).join('')
expect(text).toContain('Welcome back!')

// Second call this session is a no-op (idempotent latch).
const second: DocentStreamChunk[] = []
for await (const c of triggerOpeningTurn({ datasets, returning, config: enabledConfig })) {
second.push(c)
}
expect(second).toEqual([])
})

it('injects the returning-user block into the system prompt', async () => {
const { streamChat } = await import('./llmProvider')
let captured: Array<{ role: string; content: unknown }> | null = null
vi.mocked(streamChat).mockImplementation(async function* (messages: Array<{ role: string; content: unknown }>) {
captured = messages
yield { type: 'delta' as const, text: 'Hi' }
yield { type: 'done' as const }
})
for await (const _c of triggerOpeningTurn({ datasets, returning, config: enabledConfig })) { /* drain */ }
const system = captured!.find(m => m.role === 'system')
expect(String(system?.content)).toContain('returning visitor')
expect(String(system?.content)).toContain('Wildfire Tracker')
})
})
57 changes: 56 additions & 1 deletion src/services/docentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import type { Dataset, ChatMessage, ChatAction, DocentConfig, LegendCache, MapViewContext, LLMContextSnapshot, ReadingLevel } from '../types'
import { streamChat, checkAvailability, type AvailabilityResult, type LLMMessage, type LLMContentPart, type LLMToolCall } from './llmProvider'
import { isAvailable as isAppleIntelligenceAvailable, streamChatLocal } from './appleIntelligenceProvider'
import { buildSystemPrompt, buildCompressedHistory, buildLanguageReminderMessage, getSearchCatalogTool, getSearchDatasetsTool, getListFeaturedDatasetsTool, getLoadDatasetTool, getLoadFrameTool, getFlyToTool, getSetTimeTool, getFitBoundsTool, getAddMarkerTool, getToggleLabelsTool, getHighlightRegionTool } from './docentContext'
import { buildSystemPrompt, buildReturningUserBlock, buildCompressedHistory, buildLanguageReminderMessage, getSearchCatalogTool, getSearchDatasetsTool, getListFeaturedDatasetsTool, getLoadDatasetTool, getLoadFrameTool, getFlyToTool, getSetTimeTool, getFitBoundsTool, getAddMarkerTool, getToggleLabelsTool, getHighlightRegionTool, type ReturningUserContext } from './docentContext'
import { parseIntent, generateResponse, searchDatasets, evaluateAutoLoad } from './docentEngine'
import { clearDegraded as clearDegradedState, markDegraded as markDegradedState } from './docentDegradedState'
import { apiFetch } from './catalogSource'
Expand Down Expand Up @@ -1269,6 +1269,59 @@ async function* emitValidatedActions(
}
}

/** Session guard so the proactive greeting fires at most once per
* launch, no matter how many times catalog mode is (re)opened. */
let openingTurnFired = false

/** Test-only — reset the once-per-session opening-turn guard. */
export function resetOpeningTurnForTests(): void {
openingTurnFired = false
}

/** Synthetic instruction that drives the §9.3 opening turn. Never
* shown to the user — the actual greeting wording (and its language)
* comes from the LLM, steered by the returning-user block in the
* system prompt. */
// i18n-exempt: internal LLM prompt, not user-visible UI text.
const OPENING_TURN_INSTRUCTION =
'Greet me — I just reopened the catalog. Use the returning-visitor context in your system prompt.'

/**
* Fire Orbit's proactive opening turn for a returning visitor (§9.3).
* Idempotent within a session (refuses to re-trigger). Yields the same
* {@link DocentStreamChunk} union as {@link processMessage} so the chat
* UI renders it identically to a reply.
*
* Skips entirely (yields nothing) when the LLM is unconfigured — the
* local-engine fallback isn't a good fit for a proactive greeting (it
* would feel canned). The caller should also gate on the trigger
* conditions (catalog mode + lastSession > 24 h); this guard is the
* last line of defence + the once-per-session latch.
*/
export async function* triggerOpeningTurn(
opts: { datasets: Dataset[]; returning: ReturningUserContext; config?: DocentConfig },
): AsyncGenerator<DocentStreamChunk> {
if (openingTurnFired) return
const cfg = opts.config ?? await loadConfigWithKey()
if (!cfg.enabled || !cfg.apiUrl) return // skip when LLM unconfigured
openingTurnFired = true
const block = buildReturningUserBlock(opts.returning)
// Delegate to processMessage so the greeting reuses the full LLM
// path (streaming, discovery tools, marker → load-chip extraction).
// Empty history → turn 0; no current dataset (catalog mode).
yield* processMessage(
OPENING_TURN_INSTRUCTION,
[],
opts.datasets,
null,
cfg,
null,
undefined,
undefined,
block,
)
}

export async function* processMessage(
input: string,
history: ChatMessage[],
Expand All @@ -1278,6 +1331,7 @@ export async function* processMessage(
screenshotDataUrl?: string | null,
viewContext?: string,
mapViewContext?: MapViewContext | null,
returningUserBlock?: string | null,
): AsyncGenerator<DocentStreamChunk> {
const cfg = config ?? await loadConfigWithKey()

Expand Down Expand Up @@ -1353,6 +1407,7 @@ export async function* processMessage(
!visionActive ? currentTime : null,
qaContext || null,
mapViewContext,
returningUserBlock ?? null,
)

if (cfg.debugPrompt) {
Expand Down
17 changes: 17 additions & 0 deletions src/styles/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -731,3 +731,20 @@
padding-bottom: env(safe-area-inset-bottom);
}
}

/* §9.3 — one-time disclosure footnote under the first proactive
returning-user greeting. Muted + small so it reads as a footnote,
not part of Orbit's message. */
.chat-disclosure-footnote {
margin-top: calc(0.4rem * var(--ui-scale));
font-size: calc(0.7rem * var(--ui-scale));
color: var(--color-text-muted);
line-height: 1.4;
}
.chat-disclosure-footnote a {
color: var(--color-accent);
text-decoration: none;
}
.chat-disclosure-footnote a:hover {
text-decoration: underline;
}
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,10 @@ export interface ChatMessage {
timestamp: number
/** LLM context that produced this response (docent messages only, in-memory only) */
llmContext?: LLMContextSnapshot
/** §9.3 — when true, render the one-time "Orbit remembers locally"
* disclosure footnote under this message (the first proactive
* returning-user greeting only). */
disclosureFootnote?: boolean
}

/**
Expand Down
Loading
Loading