diff --git a/packages/ui/docs/places-llm-filters.md b/packages/ui/docs/places-llm-filters.md new file mode 100644 index 00000000..9a65261e --- /dev/null +++ b/packages/ui/docs/places-llm-filters.md @@ -0,0 +1,266 @@ +# Places LLM “AI Filters” — investigation & draft plan + +This document captures the current codebase touchpoints and a proposed architecture for **customizable grid-level LLM filters**: user-defined prompts, **target fields** exposed as **MUI DataGrid** columns/filters, persisted **cache** on `places.meta`, definitions in **user secrets**, and **batch** execution with **SSE** progress—without bloating `GridSearchResults.tsx` further. + +### Implementation order (agreed) + +1. **Server first** — New HTTP handlers and kbot batch/single logic live in **`server/src/products/places/llm.ts`** (same module family as `handleGetPlaceInfo` / `handleGetLlmRegionInfo`). Register routes next to existing place LLM routes in **`routes.ts`** / `PlacesProduct` as today. Split into e.g. `llm-grid-filters.ts` only if the file grows unwieldy. +2. **UI second** — New components for the **filter editor**: same interaction pattern as the **Context** editor (toolbar control → **collapsible panel** below the toolbar with textarea / fields, commit-on-blur where appropriate). **Do not** park this next to the view-mode buttons as a second “AI” row long term. +3. **Placement** — **Collapse AI filters under “Grid Filters”**: the **Grid filters** control (`SlidersHorizontal` + label in `GridSearchResults.tsx`) expands a section that includes both **saved type excludes** (current behavior) and **AI filter definitions** (add/remove/enable, prompts, target field, run on selection / all). Optionally fold or relocate the standalone **Sparkles / Context** strip into this panel so grid-scoped AI lives in one place. +4. **MUI last** — **`extraColumns`**, **`useAiGridColumns`**, and **`CompetitorsGridView`** wiring once API + editor exist. + +--- + +## 1. Goals + +1. **Remove** campaign-specific **`potentialCustomer`** from the place LLM summary flow (prompt + client usage) in favor of **generic, user-defined** “AI filters.” : done +2. **Extend** the existing **“Grid Filters”** UX (today: toggle + saved **type excludes** from secrets + MUI filters) with **“AI Filters”**: add/remove/enable filter definitions; run them over **current search** and/or **selected rows**. +3. **Persist filter definitions** in **`user_secrets.settings`** (same pattern as `gridsearch_exclude_types`). +4. **Persist per-place LLM outputs** in **`public.places.meta`** (JSONB), including **prompt identity**, **result**, **`computed_at`**, and **`source_user_id`** (to allow reuse / shared cache semantics). +5. **Server**: a **generic batch** runner (many places × many filters) with **SSE** notifications to the client, aligned with existing grid search streaming. +6. **Frontend**: extract **dynamic columns + row projection** into dedicated modules so `GridSearchResults.tsx` stays orchestration-only. + +--- + +## 2. Current state (inventory) + +### 2.1 Grid “Grid Filters” toggle (`GridSearchResults.tsx`) + +- State: `applySavedExcludesInGrid` toggles between: + - **Off**: `filteredCompetitors` (server/client prefilter) + URL-persisted grid filters. + - **On**: full `competitors` list + **seeded MUI column filters** on `types` via `seedExcludeTypeFilters={excludedTypes}` (`PlacesGridView` / `useGridColumns` custom operator `excludesAnyOf`). +- **Saved excludes** are loaded/stored via **`/api/me/secrets`** as `gridsearch_exclude_types` (see `client-gridsearch.ts` and `PlacesLibrary.postPlacesGridSearchExpand` reading `getUserSecrets`). + +**Implication:** “AI Filters” should **compose** with this: same DataGrid, extra **dynamic columns** whose `field` names match **target fields** stored under `meta`. + +### 2.2 MUI grid columns (`useGridColumns.tsx`, `PlacesGridView.tsx`) + +- Columns are **mostly static** (`thumbnail`, `title`, `types`, …). +- **Community DataGrid** constraint: **one filter item** in the filter model in some modes; type excludes are encoded as a **single** synthetic filter (`types` + `excludesAnyOf`). +- **Extension point:** `GridColDef[]` can be **concatenated** with dynamic defs built from enabled AI filters (string/number columns + `valueGetter` reading `row.meta?.llm_filters?.[key]` or a flattened path). + +### 2.3 Place LLM today (`server/src/products/places/llm.ts`, `place-info.md`) + +- `GET` handler resolves **`runId`** / manual **`context`**, builds `${grid_search_context}`, runs kbot completion, returns JSON including **`potentialCustomer`** (1–5) when context exists. +- **Planned product change:** drop `potentialCustomer` from this response and prompt; scoring moves to **per-filter** outputs stored in **`meta`**, not the summary tab’s single score. + +### 2.4 `places.meta` (`server/src/products/places/db-places.ts`, `places.ts`) + +- **`updatePlaceMeta(placeId, meta)`** merges/replaces JSONB as implemented today. +- Many code paths **strip** heavy keys (`pages`, `pageErrors`, `bodyHtml`) when sending to clients; any new subtree should be **namespaced** (e.g. `meta.llm_grid_filters`) to avoid accidental huge payloads if logs/debug include raw pages. + +### 2.5 SSE today (`PlacesProduct.handleGetPlacesGridSearchStream`) + +- Route: **`/api/places/gridsearch/{id}/stream`** (`routes.ts`). +- Uses Hono **`streamSSE`**, subscribes to **`EventBus`**: `job:progress`, `job:complete`, `job:failed`. +- Events already include **`location`**, **`node`**, **`job_result`**, with **hydration** from Postgres for fresh `meta` / top-level fields. + +**Implication:** batch AI filter runs can either: + +- **A)** Emit new **`EventBus`** event types (e.g. `ai_filter_progress`) **on the same `jobId` stream** (client multiplexes), or +- **B)** Add a **dedicated SSE route** (e.g. `/api/places/llm-filters/stream?jobId=…`) that only carries filter events. + +Option **A** minimizes concurrent connections if the user already holds the grid search stream open; **B** is simpler to reason about if batch jobs are decoupled from the C++ pipeline. **Recommendation:** start with **B** or a **shared helper** that writes SSE frames, then **optionally merge** into the grid stream once event shapes stabilize. + +--- + +## 3. Proposed data model + +### 3.1 User secrets — filter definitions + +Store under e.g. **`settings.places_ai_grid_filters`** (name TBD): + +```ts +type AiGridFilterDef = { + id: string; // stable UUID, used in meta keys + label: string; // UI label + prompt: string; // user text; combined with server base template + targetField: string; // slug: must match GridColDef.field, e.g. "ai_fit_seo" + enabled: boolean; // default on/off for new sessions + // optional: valueType: 'string' | 'number' | 'enum' + // optional: enumOptions for MUI singleSelect +}; +``` + +- **Validation:** `targetField` — regex `^[a-z][a-z0-9_]{0,63}$`, no collision with built-in fields (`types`, `title`, …). +- **API:** extend existing **`PATCH /api/me/secrets`** merge behavior (same as `gridsearch_exclude_types`). + +### 3.2 Per–grid-search activation + +Persist **which filters are active for this run** without a DB migration: + +- **`grid_search_runs.settings`** JSON (already used for `is_public`, etc.) **or** +- **localStorage** key `gridsearch_ai_filters_${jobId}` listing enabled `filter id`s. + +**Tradeoff:** DB settings sync across devices; localStorage is faster to ship. Plan: **settings JSON** if multi-device consistency matters; else **localStorage** for v1. + +### 3.3 `places.meta` cache subtree + +Proposed shape (exact names flexible): + +```ts +meta.llm_grid_filters = { + [filterId: string]: { + value: string | number | null; // primary cell value for MUI + quick filter + raw?: unknown; // optional full model JSON if needed + promptHash: string; // hash of def.prompt + base template version + model: string; // LLM model id + computedAt: string; // ISO + sourceUserId: string; // user who paid for the LLM call / first writer + // optional: shareable: boolean + }; +}; +``` + +**Sharing / reuse:** If another user runs the **same** `(place_id, filterId, promptHash)` and a row already exists, the server may **skip** LLM and **copy** or **reference** the cached value (policy: always allow read if place is visible to user; **write** only if owner or explicit share). **`sourceUserId`** documents provenance for audits. + +--- + +## 4. Server API sketch + +| Endpoint | Purpose | +|----------|---------| +| `POST /api/places/llm-filters/run` | Body: `{ placeIds: string[], filterIds: string[], jobId?: string }`. Enqueues work, returns `{ runId }`. | +| `GET /api/places/llm-filters/stream?runId=` **or** reuse grid SSE | SSE: `progress` (placeId, filterId, status), `cell` (placeId, filterId, value), `complete`, `error`. | +| `GET /api/places/{id}` | Existing; ensure `meta.llm_grid_filters` is included after merge (respect current stripping rules). | + +**Internals:** + +- Drive outputs through **kbot structured data** (see §4.1), not ad-hoc “JSON in prose” parsing only. +- **Concurrency:** bounded pool (env `LLM_GRID_FILTER_CONCURRENCY`); **idempotent** writes to `meta` (compare `promptHash`). +- **Auth:** same as place read; batch aborted if any `place_id` not accessible. + +### 4.1 Kbot structured output (`format`) — align with `kbot/src/zod_schema.ts` + +The kbot CLI and **`run()`** (`@polymech/kbot-d`, used from `server/src/products/places/llm.ts`) support a **`format`** option on **`IKBotTask`**, declared in **`packages/kbot/src/zod_schema.ts`** (`OptionsSchema` → generated `IKBotOptions`). + +**What it does** + +- **`format`** is transformed (CLI) from: + - a path to a **JSON Schema** `.json` file, + - a **JSON Schema** object string, + - a **Zod** schema string, or + - an in-memory **Zod** schema (`zodResponseFormat` branch in `zod_schema.ts`). + +- Under the hood, OpenAI’s **`zodResponseFormat(zodSchema, name)`** (`openai/helpers/zod`) produces a **`response_format`** payload for **chat.completions**. + +- **`run-completion.ts`** passes it through: + + ```ts + await client.chat.completions.create({ + model: options.model, + messages: params.messages, + response_format: options.format as any, + }) + ``` + +So the **model is constrained by the API** to emit JSON matching the schema, not only by prompt text. + +**Recommendation for AI grid filters** + +1. **Fixed Zod schema** in pm-pics (or shared), e.g. `AiGridFilterResultSchema`: + + - `value`: `string` (primary cell; short, filterable) or `z.union([z.string(), z.number()])` if you need numeric columns. + - Optional: `detail`, `confidence`, `labels`, etc., as needed for tooltips or future UI. + +2. Build the task roughly as: + + - `import { zodResponseFormat } from 'openai/helpers/zod'` + - `format: zodResponseFormat(AiGridFilterResultSchema, 'ai_grid_filter')` (name stable for provider logs). + - `mode: 'completion'`, `prompt` = base markdown + substitutions + user **`filter_prompt`**. + +3. **Server-side**: still **`schema.parse(JSON.parse(content))`** (or equivalent) after the completion returns, so bad provider output fails fast and never writes garbage to **`meta`**. + +4. **promptHash** should include a hash of: + - base template text, + - **serialized Zod/JSON Schema** (or a fixed **schema version** constant), + - user filter prompt, + so cache invalidation tracks **schema** changes, not only wording. + +**What to avoid** + +- Relying on **`runLlmJsonCompletion`**-style **regex strip of ` ```json `** as the **only** contract; keep it as a fallback only if **`format`** is unsupported for a given router/model. +- Letting users paste **arbitrary JSON Schema** into secrets **without validation** (injection / oversized schema). Prefer **one server-owned Zod schema** for v1; optional **preset enum variants** (e.g. “numeric score 1–5”) as separate fixed schemas later. + +**Optional asset** + +- Check in **`data/products/places/llm/grid-filter.schema.json`** mirroring the Zod shape if you want CLI parity with kbot’s **file path** `format` loading (`zod_schema.ts` lines 298–308). + +--- + +## 5. Prompt strategy (`place-base-filter.md` or not) + +**Recommendation:** + +- **One server-owned base template** (e.g. `data/products/places/llm/grid-filter.md`) that: + - Injects **place facts** (reuse `buildPlaceSubstituteVars` minus campaign context, or a slimmer variant). + - Injects **`${filter_prompt}`** from the user definition. + - States **privacy / no PII in output** (same spirit as `place-info.md`). +- **Shape of the answer** is enforced by **`format` + Zod** (§4.1), not by asking for “raw JSON only” in the prompt alone. +- **Per-filter `.md` files** are **not** required for v1; user prompt in secrets is enough. Add optional **advanced** “prompt file” path later if needed. + +This avoids proliferating files while keeping **consistent** privacy rules and **machine-verifiable** outputs. + +--- + +## 6. Frontend refactor (reduce `GridSearchResults.tsx` size) + +Suggested new files under `src/modules/places/gridsearch/`: + +| Module | Responsibility | +|--------|------------------| +| `aiGridFiltersState.ts` | Load/save defs from secrets; enabled set per `jobId`; localStorage helpers if used. | +| `useAiGridColumns.ts` | Build `GridColDef[]` from enabled defs + `valueGetter` from `meta.llm_grid_filters`. | +| `AiGridFiltersToolbar.tsx` | Add/remove/enable filters, “Run on selection / run on all” actions. | +| `PlacesGridView.tsx` (or wrapper) | Accept **optional** `extraColumns: GridColDef[]` merged after static columns. | + +`GridSearchResults.tsx` keeps: layout, view mode, wiring **props** into `CompetitorsGridView`. + +--- + +## 7. Migration from `potentialCustomer` + +1. Remove **`potentialCustomer`** from `place-info.md` and **Zod/UI** types in `LocationDetail` summary tab. : done +2. If product still needs a **single** “campaign fit” score, implement it as **one AI filter def** with a default prompt (stored in secrets or shipped as preset), not a special-case column in the LLM summary. : eventually yes, we might have default wizard driven filters + + +--- + +## 8. Risks & open questions + +- **GET URL length:** batch IDs may require **POST**-only APIs (already directionally true for run). +- **MUI single-filter constraint:** adding many AI columns may interact badly with **community** grid limits; may need to **lift** filter encoding (documented in `useGridColumns`) when AI columns need filtering simultaneously with types. +- **Meta size:** cap string length per cell; strip `raw` in list views if large. +- **Cost / abuse:** rate limits per user; optional confirmation before “run on all.” + +yeah, we take care of those but good point, we may disable certain columns for public shared searches + +--- + +## 9. Suggested implementation phases + +1. **Schema & types:** secrets shape + `meta.llm_grid_filters` TypeScript interfaces; **`AiGridFilterResultSchema`** (Zod) + **`zodResponseFormat`** wired into kbot `run()` tasks (pm-pics + shared if exported). +2. **Server:** single-place single-filter endpoint (debug) using **`format`** → validate with **`.parse()`** → batch + SSE → `updatePlaceMeta` merge. +3. **Client:** secrets editor + dynamic columns + run button; subscribe SSE and patch row state / refetch place. +4. **Cleanup:** remove `potentialCustomer` from place-info pipeline; align docs : done + +--- + +## 10. File reference cheat sheet + +| Area | Files | +|------|--------| +| Grid toggle & toolbar | `src/modules/places/gridsearch/GridSearchResults.tsx` | +| Column defs | `src/modules/places/useGridColumns.tsx`, `PlacesGridView.tsx` | +| Secrets (exclude types) | `src/modules/places/client-gridsearch.ts`, `server/.../places/places.ts` | +| Place LLM | `server/src/products/places/llm.ts`, `server/data/products/places/llm/place-info.md` | +| SSE | `server/src/products/places/index.ts` (`handleGetPlacesGridSearchStream`), `server/src/products/places/routes.ts` | +| Meta persistence | `server/src/products/places/db-places.ts` (`updatePlaceMeta`) | +| Shared place type | `packages/ui/shared/src/competitors/schemas.ts` (`PlaceSchemaFull`) | +| **Kbot `format` / options** | `polymech-mono/packages/kbot/src/zod_schema.ts` (`OptionsSchema`, `format` → `zodResponseFormat`) | +| **Kbot completion → API** | `polymech-mono/packages/kbot/src/commands/run-completion.ts` (`response_format: options.format`) | +| **Structured output example** | `polymech-mono/packages/kbot/src/examples/core/iterator-markdown-example.ts` (JSON Schema in `format`) | + +--- + +*Draft for review; no implementation committed in this step.* diff --git a/packages/ui/src/modules/places/InfoPanel.tsx b/packages/ui/src/modules/places/InfoPanel.tsx index 198a19f8..9c727f73 100644 --- a/packages/ui/src/modules/places/InfoPanel.tsx +++ b/packages/ui/src/modules/places/InfoPanel.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { ExternalLink, BookOpen, X, Info, Globe } from 'lucide-react'; import CollapsibleSection from '../../components/CollapsibleSection'; import { serverUrl } from '@/lib/db'; +import { getCurrentLang } from '@/i18n'; interface WikiResult { pageid: number; @@ -72,7 +73,10 @@ export const InfoPanel = React.memo(({ isOpen, onClose, lat, lng, locationName } setLoadingLlm(true); setLlmInfo(null); try { - const res = await fetch(`${serverUrl}/api/locations/llm-info?location=${encodeURIComponent(locationName)}`); + const lang = getCurrentLang(); + const res = await fetch( + `${serverUrl}/api/locations/llm-info?location=${encodeURIComponent(locationName)}&lang=${encodeURIComponent(lang)}`, + ); if (res.ok) { const json = await res.json(); setLlmInfo(json.data); diff --git a/packages/ui/src/modules/places/LocationDetail.tsx b/packages/ui/src/modules/places/LocationDetail.tsx index af1f9c82..7eaf4bf0 100644 --- a/packages/ui/src/modules/places/LocationDetail.tsx +++ b/packages/ui/src/modules/places/LocationDetail.tsx @@ -1,27 +1,46 @@ import React, { useEffect, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter, Star, Fingerprint, ListOrdered, Loader2 } from 'lucide-react'; +import { MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter, Star, Fingerprint, ListOrdered, Loader2, Sparkles, Building2, GitBranch, Users } from 'lucide-react'; import Lightbox from "yet-another-react-lightbox"; import "yet-another-react-lightbox/styles.css"; import { API_URL, THUMBNAIL_WIDTH } from '../../constants'; import type { PlaceFull } from '@polymech/shared'; -import { fetchCompetitorById, fetchPlacePhotos } from './client-gridsearch'; +import { fetchCompetitorById, fetchPlacePhotos, fetchPlaceLlmSummary, type PlaceLlmSummary } from './client-gridsearch'; import MarkdownRenderer from '../../components/MarkdownRenderer'; -import { T, translate } from '../../i18n'; +import { T, translate, getCurrentLang } from '../../i18n'; // Extracted Presentation Component -export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos }: { competitor: PlaceFull; onClose?: () => void; livePhotos?: any }) => { +export const LocationDetailView = React.memo(({ + competitor, + onClose, + livePhotos, + gridSearchRunId, + potentialCustomerContext, +}: { + competitor: PlaceFull; + onClose?: () => void; + livePhotos?: any; + /** When set (e.g. grid search job id), server loads grid_search_runs.request for campaign fit scoring */ + gridSearchRunId?: string | null; + /** When set, overrides grid run JSON for Potential customer scoring (user “Context” in grid search UI) */ + potentialCustomerContext?: string | null; +}) => { const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); - const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'summary' | 'homepage' | 'debug'>('overview'); const [fetchedPhotos, setFetchedPhotos] = useState(null); const [isFetchingPhotos, setIsFetchingPhotos] = useState(false); + const [summaryData, setSummaryData] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(false); + const [summaryError, setSummaryError] = useState(null); const showDebug = import.meta.env.VITE_LOCATION_DETAIL_DEBUG === 'true'; useEffect(() => { // Reset local fetched state when competitor changes setFetchedPhotos(null); setIsFetchingPhotos(false); + setSummaryData(null); + setSummaryError(null); // Fetch photos on-the-fly (async, non-blocking) if we don't already have them if (!livePhotos && !competitor.raw_data?.google_media?.photos?.length) { @@ -33,8 +52,48 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos } }, [competitor.place_id, livePhotos, competitor.raw_data?.google_media?.photos?.length]); + useEffect(() => { + if (activeTab !== 'summary' || !competitor.place_id) return; + let cancelled = false; + setSummaryLoading(true); + setSummaryError(null); + const lang = getCurrentLang(); + fetchPlaceLlmSummary(competitor.place_id, { + lang, + runId: gridSearchRunId || undefined, + context: potentialCustomerContext?.trim() || undefined, + }) + .then((data) => { + if (!cancelled) setSummaryData(data); + }) + .catch((e: Error) => { + if (!cancelled) setSummaryError(e?.message || 'Failed to load summary'); + }) + .finally(() => { + if (!cancelled) setSummaryLoading(false); + }); + return () => { + cancelled = true; + }; + }, [activeTab, competitor.place_id, gridSearchRunId, potentialCustomerContext]); + // Prefer prop-injected, then dynamically fetched, then DB-cached const photoSource = livePhotos || fetchedPhotos || competitor.raw_data?.google_media; + const summaryHasContent = + summaryData && + !!( + summaryData.summary || + (summaryData.services?.length ?? 0) > 0 || + (summaryData.industries?.length ?? 0) > 0 || + (summaryData.products?.length ?? 0) > 0 || + (summaryData.priceRange && String(summaryData.priceRange).length > 0) || + (summaryData.companySizeEstimate && String(summaryData.companySizeEstimate).length > 0) || + summaryData.differentiators || + typeof summaryData.potentialCustomer === 'number' || + summaryData.isBranch === true || + summaryData.isFamilyBusiness === true || + summaryData.isFamilyBusiness === false + ); const photos = photoSource?.photos?.map((photo: any) => ({ src: photo.image, alt: competitor.title, @@ -132,18 +191,27 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos )} - {(showDebug || competitor.website) && ( -
-
)} + ) : activeTab === 'summary' ? ( +
+
+

+ + Summary + {summaryLoading && } +

+
+
+ {summaryError && ( +
+ {summaryError} +
+ )} + {!summaryLoading && !summaryError && summaryData && ( + <> + {summaryData.summary && ( +
+

Details

+

{summaryData.summary}

+
+ )} +
+ {summaryData.isBranch === true && ( + + + Branch / part of larger firm + + )} + {summaryData.isFamilyBusiness === true && ( + + + Family business + + )} + {summaryData.isFamilyBusiness === false && ( + + + Not indicated as family-owned + + )} +
+ {(summaryData.companySizeEstimate || summaryData.priceRange) && ( +
+ {summaryData.companySizeEstimate && ( +
+
Company size (estimate)
+
{summaryData.companySizeEstimate}
+
+ )} + {summaryData.priceRange ? ( +
+
Price range
+
{summaryData.priceRange}
+
+ ) : null} +
+ )} + {summaryData.services && summaryData.services.length > 0 && ( +
+

Services

+
    + {summaryData.services.map((s, i) => ( +
  • {s}
  • + ))} +
+
+ )} + {summaryData.industries && summaryData.industries.length > 0 && ( +
+

Industries

+
    + {summaryData.industries.map((s, i) => ( +
  • {s}
  • + ))} +
+
+ )} + {summaryData.products && summaryData.products.length > 0 && ( +
+

Products

+
+ + + + + + + + + {summaryData.products.map((p, i) => ( + + + + + ))} + +
TypePrice range
{p.type || '—'}{p.priceRange || '—'}
+
+
+ )} + {summaryData.differentiators && ( +
+

Differentiators

+

{summaryData.differentiators}

+
+ )} + + )} + {!summaryLoading && !summaryError && summaryData && !summaryHasContent && ( +

No summary fields returned.

+ )} +
+
) : activeTab === 'homepage' && competitor.website ? (
diff --git a/packages/ui/src/modules/places/PlacesMapView.tsx b/packages/ui/src/modules/places/PlacesMapView.tsx index adf89f69..07b77d05 100644 --- a/packages/ui/src/modules/places/PlacesMapView.tsx +++ b/packages/ui/src/modules/places/PlacesMapView.tsx @@ -215,6 +215,8 @@ export const PlacesMapView: React.FC = ({ places, onMapCente raw: r.raw || { gid: r.gid, gadmName: r.name, level: r.level } })); }); + /** When false (`regions=0`), we keep guided-area metadata for Expand etc. but do not fetch boundaries. */ + const showRegionBoundaries = features.showRegions !== false; const [pickerPolygons, setPickerPolygons] = useState([]); const [simulatorData, setSimulatorData] = useState(null); const [simulatorPath, setSimulatorPath] = useState(null); @@ -240,19 +242,26 @@ export const PlacesMapView: React.FC = ({ places, onMapCente const { mapInternals, currentCenterLabel, isLocating, setupMapListeners, handleLocate, cleanupLocateMarker } = useMapControls(onMapCenterUpdate); - // Needed for widget mode: Auto-load GADM region boundaries when initialGadmRegions changes + // Auto-load GADM boundaries when guided areas exist and region layers are enabled (skip network when `regions=0`). useEffect(() => { + if (!showRegionBoundaries) { + setPickerPolygons([]); + lastInitialGidsRef.current = null; + return; + } if (!features.enableAutoRegions || !initialGadmRegions || initialGadmRegions.length === 0) return; - // Prevent redundant loads if gids haven't changed (prevents flickering Expand button) const gidsKey = initialGadmRegions.map(r => r.gid).sort().join(','); if (lastInitialGidsRef.current === gidsKey) return; lastInitialGidsRef.current = gidsKey; + let cancelled = false; + (async () => { const regions: any[] = []; const polygons: any[] = []; for (const region of initialGadmRegions) { + if (cancelled) return; regions.push({ gid: region.gid, gadmName: region.name, @@ -266,13 +275,16 @@ export const PlacesMapView: React.FC = ({ places, onMapCente console.error('Failed to fetch boundary for', region.gid, err); } } + if (cancelled) return; setPickerRegions(regions); setPickerPolygons(polygons); - - // Allow bounds to be fitted again for new regions hasFittedBoundsRef.current = false; })(); - }, [features.enableAutoRegions, initialGadmRegions]); + + return () => { + cancelled = true; + }; + }, [showRegionBoundaries, features.enableAutoRegions, initialGadmRegions]); const onRegionsChangeRef = useRef(onRegionsChange); useEffect(() => { diff --git a/packages/ui/src/modules/places/client-gridsearch.ts b/packages/ui/src/modules/places/client-gridsearch.ts index 44c49b17..f652d627 100644 --- a/packages/ui/src/modules/places/client-gridsearch.ts +++ b/packages/ui/src/modules/places/client-gridsearch.ts @@ -90,6 +90,40 @@ export const fetchPlacePhotos = async (placeId: string): Promise => { return res.data; }; +/** LLM-generated place summary from /api/places/:id/place-info */ +export interface PlaceLlmSummary { + summary?: string; + services?: string[]; + industries?: string[]; + products?: { type?: string; priceRange?: string }[]; + priceRange?: string; + companySizeEstimate?: string; + isBranch?: boolean; + isFamilyBusiness?: boolean | null; + /** 1–5 when grid search run context was sent; null otherwise */ + potentialCustomer?: number | null; + differentiators?: string; +} + +export const fetchPlaceLlmSummary = async ( + placeId: string, + opts?: { lang?: string; runId?: string | null; context?: string | null }, +): Promise => { + const params = new URLSearchParams(); + if (opts?.lang && opts.lang.length >= 2) { + params.set('lang', opts.lang.slice(0, 2).toLowerCase()); + } + if (opts?.runId) { + params.set('runId', opts.runId); + } + if (opts?.context && opts.context.trim()) { + params.set('context', opts.context.trim().slice(0, 8000)); + } + const q = params.toString() ? `?${params.toString()}` : ''; + const res = await apiClient<{ data: PlaceLlmSummary }>(`/api/places/${placeId}/place-info${q}`); + return res.data; +}; + // --- Places GridSearch API Methods (New C++ backend) --- export const fetchPlacesGridSearches = async (): Promise => { diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index 17fb5daf..7ce01c8c 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -1,6 +1,6 @@ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge, Pause, Play, Square, SlidersHorizontal } from 'lucide-react'; +import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge, Pause, Play, Square, SlidersHorizontal, Sparkles } from 'lucide-react'; import { CompetitorsGridView, type CompetitorsGridViewHandle } from '../PlacesGridView'; import { PlacesMapView, type MapFeatures } from '../PlacesMapView'; @@ -151,6 +151,31 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes const [gridFilterRemountKey, setGridFilterRemountKey] = useState(0); const gridExportRef = useRef(null); + const pcContextStorageKey = `gridsearch_pc_context_${jobId}`; + const [aiContextOpen, setAiContextOpen] = useState(false); + /** Committed value: localStorage + passed to place LLM (Potential customer). Updated on textarea blur only. */ + const [potentialCustomerContext, setPotentialCustomerContext] = useState(''); + /** Draft while typing; does not persist or affect scoring until blur. */ + const [pcContextDraft, setPcContextDraft] = useState(''); + + useEffect(() => { + try { + const stored = localStorage.getItem(pcContextStorageKey) ?? ''; + setPotentialCustomerContext(stored); + setPcContextDraft(stored); + } catch { + setPotentialCustomerContext(''); + setPcContextDraft(''); + } + }, [pcContextStorageKey]); + + const commitPcContext = useCallback(() => { + setPotentialCustomerContext(pcContextDraft); + try { + localStorage.setItem(pcContextStorageKey, pcContextDraft); + } catch { /* quota */ } + }, [pcContextDraft, pcContextStorageKey]); + const toggleApplySavedExcludesInGrid = useCallback(() => { setApplySavedExcludesInGrid((v) => !v); setGridFilterRemountKey((k) => k + 1); @@ -417,7 +442,8 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes )}
)} -
+
+
{/* Left: sidebar toggle + expand button */}
{onToggleSidebar && ( @@ -571,6 +597,20 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes )}
+ + {isOwner && ( <>
@@ -655,7 +695,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes )} - {isOwner && viewMode === 'poster' && ( + {viewMode === 'poster' && ( <>
@@ -685,6 +725,32 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes )}
+
+ + {aiContextOpen && ( +
+ +