lollipop match 1/2

This commit is contained in:
lovebird 2026-04-15 17:33:18 +02:00
parent 4789e0ba19
commit 9024b71e6c
6 changed files with 586 additions and 21 deletions

View File

@ -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`** (15) 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 tabs 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 Pergrid-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, OpenAIs **`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 15”) 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 kbots **file path** `format` loading (`zod_schema.ts` lines 298308).
---
## 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.*

View File

@ -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);

View File

@ -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<any>(null);
const [isFetchingPhotos, setIsFetchingPhotos] = useState(false);
const [summaryData, setSummaryData] = useState<PlaceLlmSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(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
</div>
)}
{(showDebug || competitor.website) && (
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex flex-wrap gap-x-6 gap-y-1 px-4" aria-label="Tabs">
<button
onClick={() => setActiveTab('overview')}
className={`${activeTab === 'overview'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors`}
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors inline-flex items-center gap-1.5`}
>
<T>Overview</T>
</button>
<button
onClick={() => setActiveTab('summary')}
className={`${activeTab === 'summary'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors inline-flex items-center gap-1.5`}
>
<Sparkles className="h-4 w-4 shrink-0 opacity-80" />
<T>Summary</T>
</button>
{competitor.website && (
<button
onClick={() => setActiveTab('homepage')}
@ -168,8 +236,6 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos
)}
</nav>
</div>
)}
{activeTab === 'overview' ? (
<>
@ -387,6 +453,121 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos
</div>
)}
</>
) : activeTab === 'summary' ? (
<div className={`bg-white/80 dark:bg-gray-800/70 backdrop-blur-sm ${!onClose && 'shadow overflow-hidden sm:rounded-lg'} p-2`}>
<div className={!onClose ? 'px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700' : 'mb-3'}>
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-indigo-500 shrink-0" />
<T>Summary</T>
{summaryLoading && <Loader2 className="h-4 w-4 animate-spin text-indigo-500" />}
</h3>
</div>
<div className="px-4 py-5 sm:px-6 space-y-6">
{summaryError && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-800 dark:text-red-200">
{summaryError}
</div>
)}
{!summaryLoading && !summaryError && summaryData && (
<>
{summaryData.summary && (
<div>
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2"><T>Details</T></h4>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{summaryData.summary}</p>
</div>
)}
<div className="flex flex-wrap gap-2">
{summaryData.isBranch === true && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-100 px-2.5 py-0.5 text-xs font-medium">
<GitBranch className="h-3.5 w-3.5" />
<T>Branch / part of larger firm</T>
</span>
)}
{summaryData.isFamilyBusiness === true && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 text-emerald-900 dark:bg-emerald-900/40 dark:text-emerald-100 px-2.5 py-0.5 text-xs font-medium">
<Users className="h-3.5 w-3.5" />
<T>Family business</T>
</span>
)}
{summaryData.isFamilyBusiness === false && (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200 px-2.5 py-0.5 text-xs font-medium">
<Building2 className="h-3.5 w-3.5" />
<T>Not indicated as family-owned</T>
</span>
)}
</div>
{(summaryData.companySizeEstimate || summaryData.priceRange) && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
{summaryData.companySizeEstimate && (
<div>
<dt className="text-gray-500 dark:text-gray-400 font-medium"><T>Company size (estimate)</T></dt>
<dd className="mt-0.5 text-gray-900 dark:text-gray-100">{summaryData.companySizeEstimate}</dd>
</div>
)}
{summaryData.priceRange ? (
<div>
<dt className="text-gray-500 dark:text-gray-400 font-medium"><T>Price range</T></dt>
<dd className="mt-0.5 text-gray-900 dark:text-gray-100">{summaryData.priceRange}</dd>
</div>
) : null}
</dl>
)}
{summaryData.services && summaryData.services.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2"><T>Services</T></h4>
<ul className="flex flex-wrap gap-1.5">
{summaryData.services.map((s, i) => (
<li key={i} className="px-2 py-0.5 rounded-md bg-indigo-50 dark:bg-indigo-900/30 text-indigo-900 dark:text-indigo-100 text-xs">{s}</li>
))}
</ul>
</div>
)}
{summaryData.industries && summaryData.industries.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2"><T>Industries</T></h4>
<ul className="flex flex-wrap gap-1.5">
{summaryData.industries.map((s, i) => (
<li key={i} className="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-100 text-xs">{s}</li>
))}
</ul>
</div>
)}
{summaryData.products && summaryData.products.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2"><T>Products</T></h4>
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-600">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800/80 text-left text-xs text-gray-600 dark:text-gray-400">
<th className="px-3 py-2 font-medium"><T>Type</T></th>
<th className="px-3 py-2 font-medium"><T>Price range</T></th>
</tr>
</thead>
<tbody>
{summaryData.products.map((p, i) => (
<tr key={i} className="border-t border-gray-200 dark:border-gray-700">
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">{p.type || '—'}</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{p.priceRange || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{summaryData.differentiators && (
<div>
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2"><T>Differentiators</T></h4>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{summaryData.differentiators}</p>
</div>
)}
</>
)}
{!summaryLoading && !summaryError && summaryData && !summaryHasContent && (
<p className="text-sm text-gray-500 dark:text-gray-400"><T>No summary fields returned.</T></p>
)}
</div>
</div>
) : activeTab === 'homepage' && competitor.website ? (
<div className={`mt-4 bg-white dark:bg-gray-800 ${!onClose && 'shadow sm:rounded-lg'} overflow-hidden min-h-[600px] flex flex-col`}>
<div className="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex items-center justify-between text-xs">

View File

@ -215,6 +215,8 @@ export const PlacesMapView: React.FC<PlacesMapViewProps> = ({ 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<any[]>([]);
const [simulatorData, setSimulatorData] = useState<any>(null);
const [simulatorPath, setSimulatorPath] = useState<any>(null);
@ -240,19 +242,26 @@ export const PlacesMapView: React.FC<PlacesMapViewProps> = ({ 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<PlacesMapViewProps> = ({ 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(() => {

View File

@ -90,6 +90,40 @@ export const fetchPlacePhotos = async (placeId: string): Promise<any> => {
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;
/** 15 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<PlaceLlmSummary> => {
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<GridSearchSummary[]> => {

View File

@ -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<CompetitorsGridViewHandle | null>(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
)}
</div>
)}
<div className={`flex justify-between items-center mb-4 px-2 ${streaming ? 'mt-1' : 'mt-2'}`}>
<div className={`flex flex-col gap-2 mb-4 px-2 ${streaming ? 'mt-1' : 'mt-2'}`}>
<div className="flex justify-between items-center">
{/* Left: sidebar toggle + expand button */}
<div className="flex items-center gap-2">
{onToggleSidebar && (
@ -571,6 +597,20 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
)}
</div>
<button
type="button"
onClick={() => setAiContextOpen((o) => !o)}
className={`flex items-center gap-1 p-1.5 rounded-md transition-all shrink-0 ${
aiContextOpen
? 'bg-violet-50 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200 ring-1 ring-violet-300 dark:ring-violet-700'
: 'text-gray-400 hover:text-violet-600 dark:hover:text-violet-300 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
title={translate('AI: edit context for Potential customer scoring')}
>
<Sparkles className="h-4 w-4" />
<span className="hidden sm:inline text-xs font-semibold tracking-tight">AI</span>
</button>
{isOwner && (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1" />
@ -655,7 +695,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
</DropdownMenu>
)}
{isOwner && viewMode === 'poster' && (
{viewMode === 'poster' && (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
<div className="flex items-center gap-1.5 px-2 bg-indigo-50/50 dark:bg-indigo-900/30 rounded-md">
@ -685,6 +725,32 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
</>
)}
</div>
</div>
{aiContextOpen && (
<div className="rounded-lg border border-violet-200 dark:border-violet-800/60 bg-violet-50/40 dark:bg-violet-950/20 px-3 py-2 space-y-1.5">
<label className="block text-xs font-medium text-violet-900 dark:text-violet-200">
<T>Context</T>
<span className="font-normal text-violet-600/90 dark:text-violet-300/80 ml-1">
(<T>AI Context for Potential Customer Matching</T>)
</span>
</label>
<textarea
value={pcContextDraft}
onChange={(e) => setPcContextDraft(e.target.value)}
onBlur={commitPcContext}
rows={4}
maxLength={8000}
placeholder={translate(
'e.g. We want B2B marketing agencies; prioritize firms offering SEO and paid ads…',
)}
className="w-full text-sm rounded-md border border-violet-200 dark:border-violet-800 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-2 py-1.5 placeholder:text-gray-400 focus:ring-2 focus:ring-violet-400/50 focus:border-violet-400 outline-none resize-y min-h-[88px]"
/>
<p className="text-[10px] text-violet-700/80 dark:text-violet-300/70">
{pcContextDraft.length}/8000 · <T>Saved per search in this browser</T>
</p>
</div>
)}
</div>
<div className="flex-1 flex flex-row min-h-0 overflow-hidden">
@ -802,6 +868,8 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
<LocationDetailView
competitor={activeCompetitor}
onClose={() => setShowDetails(false)}
gridSearchRunId={jobId}
potentialCustomerContext={potentialCustomerContext.trim() || undefined}
/>
</div>
</div>