diff --git a/packages/ui/docs/places-llm-filters.md b/packages/ui/docs/places-llm-filters.md index bdda7846..73597087 100644 --- a/packages/ui/docs/places-llm-filters.md +++ b/packages/ui/docs/places-llm-filters.md @@ -1,274 +1,181 @@ -# Places LLM “AI Filters” — investigation & draft plan +# Places AI grid filters -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. +This document describes the **AI grid filters** feature: user-defined prompts stored in **user secrets**, batch LLM evaluation over grid search results, results cached on **`places.meta`**, and **MUI DataGrid** columns driven by those definitions. -### 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. +It replaces the older **per-search “potential customer” context** on place summaries; scoring and extraction are **per filter definition** under `meta.llm_grid_filters`. --- -## 1. Goals +## Overview -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. +1. **Definitions** live in `user_secrets.settings.places_ai_grid_filters` (array), edited in the **Grid filters** panel when the grid search is expanded. +2. **Runs** call `POST /api/places/llm-filters/run` with `placeIds` and `filterIds`; the server evaluates each pair, merges into `places.meta.llm_grid_filters`, and emits **SSE** events on `GET /api/places/llm-filters/stream?runId=…`. +3. **Columns** are built client-side (`useAiGridColumns`) from enabled definitions: `GridColDef.field === targetField`, cell value from `row.llm_grid_filters[filterId].value` (by **filter UUID**, not by `targetField` key in meta — see below). + +```mermaid +flowchart LR + subgraph secrets["User secrets"] + defs["places_ai_grid_filters[]"] + end + subgraph server["Server"] + run["POST /llm-filters/run"] + llm["grid-filter.md + LLM"] + meta["places.meta.llm_grid_filters"] + end + subgraph client["Client"] + sse["EventSource stream"] + grid["DataGrid extraColumns"] + end + defs --> run + run --> llm --> meta + run --> sse + sse --> grid + meta --> grid +``` --- -## 2. Current state (inventory) +## Filter definitions (`places_ai_grid_filters`) -### 2.1 Grid “Grid Filters” toggle (`GridSearchResults.tsx`) +Stored as JSON via existing **`/api/me/secrets`** (same pattern as `gridsearch_exclude_types`). TypeScript: `PlacesAiGridFilterDef` in `src/modules/places/client-gridsearch.ts`. -- 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`). +| Field | Description | +|--------|-------------| +| `id` | Stable UUID; **key** for `meta.llm_grid_filters[id]` and SSE payloads. | +| `label` | Optional display name for the column header. If empty or the legacy default “New filter”, the UI falls back to **`targetField`**. | +| `prompt` | User instructions combined with the server template `grid-filter.md`. | +| `targetField` | Slug for **`GridColDef.field`** (must match `^[a-z][a-z0-9_]{0,63}$`). Must **not** collide with built-in columns (`types`, `title`, … — see `RESERVED_GRID_FIELDS` in `llm.ts`). | +| `enabled` | Optional; default **on**. Disabled filters are skipped by the runner. | +| `valueType` | Optional: **`auto`** (default), **`number`**, or **`string`**. Controls coercion of the LLM `value` for sorting/filtering and MUI column `type`. Included in **prompt hash** so changing it invalidates cached cells until you re-run. | -**Target UX:** Introduce a **`gridFiltersOpen`** (name TBD) collapsible **below** the main toolbar row—same structural pattern as **`aiContextOpen`** + violet panel (see ~`{aiContextOpen && (…textarea…)}`). **Grid filters** becomes the parent affordance: expanded panel holds **type-exclude / MUI hint copy** and the **AI filter editor** (not a duplicate top-level “AI” entry). Today the **Sparkles** control sits beside view modes; plan is to **merge** grid-related AI into this **Grid filters** section. - -**Implication:** “AI Filters” **compose** with existing grid behavior: 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 summary JSON for the place detail tab. -- **Product direction:** campaign fit moves to **per-filter** outputs in **`meta.llm_grid_filters`**, not a dedicated **`potentialCustomer`** field on the summary response (removed from prompt/UI per §7). - -### 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. +**UI:** `GridFiltersPanel.tsx` — add/remove filters, save definitions, run on selection or all rows (owner-only). --- -## 3. Proposed data model +## Per-place cache (`meta.llm_grid_filters`) -### 3.1 User secrets — filter definitions - -Store under e.g. **`settings.places_ai_grid_filters`** (name TBD): +Merged with `updatePlaceMeta`. Shape per filter id: ```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 +meta.llm_grid_filters[filterId] = { + value: string | number; // normalized for the grid (see below) + raw: { value, value_type?, detail? }; // parsed LLM JSON (value may match stored value after normalization) + promptHash: string; // invalidates when template version, prompt, targetField, or valueType changes + model: string; // resolved model id at run time + computedAt: string; // ISO timestamp + sourceUserId: string; // user who ran the job }; ``` -- **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. +**Skip / idempotency:** If `promptHash` for this filter already matches the stored entry, the server **skips** another LLM call for that `(placeId, filterId)` until the definition or template version changes. --- -## 4. Server API sketch +## LLM contract -**Home for handlers:** implement in **`server/src/products/places/llm.ts`** (re-export or register from `PlacesProduct` like existing LLM routes). Keeps kbot tasks, Zod `format`, and prompt path helpers in one place. +### Prompt template -| 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). | +- Default path: `server/data/products/places/llm/grid-filter.md`. +- Override: env **`LLM_GRID_FILTER_PROMPT_PATH`** (absolute or relative to server cwd as implemented by `resolvePromptPath`). +- Substitutions include place fields from `buildPlaceSubstituteVars`, plus `filter_prompt`, `filter_label`, `target_field`, **`value_type_hint`** (from `valueType`: auto / number / string). -**Internals:** +### JSON response (`AiGridFilterResultSchema`) -- 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. +| Field | Type | Description | +|--------|------|-------------| +| `value` | `string \| number` | Primary cell value (short; no PII per prompt). | +| `value_type` | optional `"number" \| "string"` | **Guided** hint: use `"number"` for numeric scales (1–5) so the server coerces and DataGrid can filter numerically. | +| `detail` | optional `string` | Longer rationale. | -### 4.1 Kbot structured output (`format`) — align with `kbot/src/zod_schema.ts` +After parse, **`normalizeAiGridFilterValue`** applies **user `valueType`** and/or **`value_type`** and/or numeric-string heuristics so `meta` stores a **number** when appropriate (see `server/src/products/places/llm.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`). +### Template version -**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). +- Constant **`LLM_GRID_FILTER_TEMPLATE_VERSION`** (currently **`2`**) is part of **`promptHash`**. Bump it when the base template or normalization rules change in a way that should force re-computation. --- -## 5. Prompt strategy (`place-base-filter.md` or not) +## Environment variables (grid filter LLM) -**Recommendation:** +Resolution order for **provider**: -- **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. +1. `LLM_GRID_FILTER_PROVIDER` +2. `LLM_PLACE_FILTERS_PROVIDER` +3. `LLM_PLACE_PROVIDER` +4. `LLM_REGION_PROVIDER` +5. default `openrouter` -This avoids proliferating files while keeping **consistent** privacy rules and **machine-verifiable** outputs. +Resolution order for **model**: + +1. `LLM_GRID_FILTER_MODEL` +2. `LLM_PLACE_FILTERS_MODEL` +3. `LLM_PLACE_MODEL` +4. `LLM_REGION_MODEL` +5. default `openai/gpt-5.2` + +Set e.g. `LLM_PLACE_FILTERS_PROVIDER` / `LLM_PLACE_FILTERS_MODEL` in `server/.env` when you want grid filters to use a dedicated model without changing place-info or region LLMs. --- -## 6. Frontend (after server): Grid Filters panel + components +## HTTP API -**Pattern:** Reuse the **Context** editor UX: a **toolbar button** toggles open state; when open, a **full-width panel** under the toolbar (bordered card, compact labels) holds forms. Apply the same for **AI filters** inside the **Grid filters** region—**not** a separate floating section next to view modes. +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/api/places/llm-filters/run` | Body: `{ placeIds: string[], filterIds: string[], lang?: string }`. Returns **`202`** with `{ data: { runId } }`. Max **80** place ids, **20** filter ids (see route validation). | +| `GET` | `/api/places/llm-filters/stream?runId=` | **SSE** (`text/event-stream`). For browsers, pass **`?token=`** (auth) when using `EventSource`. Events include progress, per-cell updates, completion, errors. | -Suggested new files under `src/modules/places/gridsearch/`: - -| Module | Responsibility | -|--------|------------------| -| `GridFiltersPanel.tsx` (or split) | Collapsible content for **Grid filters**: type-exclude explanation, **AI filter list** (add/remove/enable), prompts, target field, run actions. Consumed when `gridFiltersOpen` (or merged state with `applySavedExcludesInGrid`). | -| `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`. | -| `PlacesGridView.tsx` (or wrapper) | Accept **optional** `extraColumns: GridColDef[]` merged after static columns. | - -`GridSearchResults.tsx` stays layout-only: view modes, **`GridFiltersPanel`** slot, **`CompetitorsGridView`** props. - -**Phase order:** build **Grid Filters** collapsible + editor **after** endpoints exist (mock with static data if needed); add **MUI dynamic columns** once `meta.llm_grid_filters` flows from the server. +Handlers: `handlePostLlmFiltersRun`, `handleGetLlmFiltersStream` in `server/src/products/places/llm.ts`; route definitions in `server/src/products/places/routes.ts`. --- -## 7. Migration from `potentialCustomer` +## Frontend -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 +| Area | Behavior | +|------|----------| +| `GridSearchResults.tsx` | Loads filter defs (`getPlacesAiGridFilters`), passes **`extraColumns={useAiGridColumns(aiFilterDefs)}`** to the grid for **owners**; wires `GridFiltersPanel`, SSE refresh hooks. | +| `useAiGridColumns.tsx` | Builds `GridColDef[]`: `field: targetField`, `headerName` from label / targetField, **`type: 'number' \| 'string'`** when `valueType` is set, `valueGetter` reads `row.llm_grid_filters[def.id].value`. | +| `PlacesGridView.tsx` | Merges **`extraColumns`** after base columns; filter model sync when toggling **“Apply saved type excludes as DataGrid filters”** without remounting the grid (preserves column visibility/order). | +| `GridFiltersPanel.tsx` | Edit definitions, save to secrets, run batch. | +**Auth:** Only the search **owner** sees the AI filter editor and dynamic columns; viewers use the standard grid without those columns. --- -## 8. Risks & open questions +## Numeric scores (1–5, etc.) -- **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.” -- **Public / shared grid searches:** hide or disable **AI filter** columns and editor affordances for viewers when `is_public` and `!isOwner` (same spirit as hiding sensitive tools); only owner runs LLM fills that write `meta`. +1. Prefer **`valueType: number`** on the filter (dropdown in the panel) so the column is typed as numeric in MUI. +2. In the prompt, ask for a **JSON number** and optionally **`"value_type":"number"`** in the model output (see `grid-filter.md`). +3. **Re-run** AI filters after changing `valueType` or normalization logic so old string cells are replaced. --- -## 9. Suggested implementation phases (matches § order at top) +## Related features -1. **Server (`llm.ts` + routes):** Zod **`AiGridFilterResultSchema`** + **`zodResponseFormat`**; single-place single-filter handler (debug); **`POST …/run`** + **`GET …/stream`** (or EventBus bridge); **`updatePlaceMeta`** merge with **`promptHash`** checks. -2. **Client — Grid Filters UI:** **`GridFiltersPanel`** (or equivalent); filter editor **like Context** (collapsible under **Grid filters**); wire secrets **`PATCH /api/me/secrets`** for definitions; run actions calling batch API + SSE. -3. **Client — MUI:** **`useAiGridColumns`** + **`CompetitorsGridView`** `extraColumns`; row refresh on SSE / refetch. -4. **Product cleanup:** `potentialCustomer` removal from place-info pipeline — **done** where noted in §1 / §7. +- **Saved type excludes:** Same **Grid filters** panel; `gridsearch_exclude_types` + optional **seeded** `types` column filter when “Apply saved type excludes as DataGrid filters” is on (`seedExcludeTypeFilters` / `PlacesGridView`). +- **Place tab LLM** (`place-info.md`): Separate flow; does not use `places_ai_grid_filters` definitions. --- -## 10. File reference cheat sheet +## File reference | 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 & **new grid-filter LLM** | `server/src/products/places/llm.ts` (handlers + kbot tasks), `server/data/products/places/llm/place-info.md`, `grid-filter.md` (TBD) | -| Grid Filters UI / Context pattern | `src/modules/places/gridsearch/GridSearchResults.tsx` (toolbar; collapse under Grid filters per §6) | -| 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`) | +| Server logic & SSE | `server/src/products/places/llm.ts` | +| Routes | `server/src/products/places/routes.ts`, `server/src/products/places/index.ts` | +| Prompt | `server/data/products/places/llm/grid-filter.md` | +| Client API & types | `src/modules/places/client-gridsearch.ts` | +| Grid UI | `src/modules/places/gridsearch/GridSearchResults.tsx`, `GridFiltersPanel.tsx` | +| Dynamic columns | `src/modules/places/gridsearch/useAiGridColumns.tsx` | +| Grid shell | `src/modules/places/PlacesGridView.tsx`, `useGridColumns.tsx` | --- -*Living plan — implementation follows server (`llm.ts`) → Grid Filters panel + editor → MUI columns.* +## Operational notes + +- **Cost / abuse:** Batch size is capped; consider rate limits per deployment. +- **Meta size:** Keep `value` / `detail` short; avoid large blobs in `raw` if list payloads grow. +- **Public grid searches:** Non-owners do not get AI filter columns or the editor; LLM writes require an authenticated owner context for runs tied to their secrets. + +--- + +*Implementation reference — behavior is defined by `server/src/products/places/llm.ts` and the routes above.*