Skip to content

Commit 9844cd8

Browse files
aaronbrezelclaude
andauthored
feat: dynamic column mapping for Run AI (#22)
* docs: add dynamic column mapping design for runBatchAI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add dynamic column mapping implementation plan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: make runInference driveLinks and systemPrompt optional params Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: update stale JSDoc and test descriptions for optional runInference params Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add resolveColumns pure helper to utils Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace AIMode/ColumnMap with RunConfig, rewrite runBatchAI, add getSheetHeaders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guard empty sheet in runBatchAI, sync rollup footer stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: delete dialog.ts — replaced by sidebar config panel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace AI modal with inline sidebar config panel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove dead markup in sidebar, final verification Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace checkbox lists with pill tags, reset run button after success Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: port pill tag UX to system prompt and output column fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0bb792c commit 9844cd8

12 files changed

Lines changed: 1784 additions & 192 deletions

__tests__/inference.test.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@ describe("runInference", () => {
4949

5050
it("returns the model response string for a scalar user prompt", () => {
5151
mockOkResponse("AI response");
52-
expect(runInference("Hello AI", null, null)).toBe("AI response");
52+
expect(runInference("Hello AI")).toBe("AI response");
5353
});
5454

5555
it("returns null when userPrompts flattens to empty", () => {
56-
expect(runInference(null, null, null)).toBeNull();
57-
expect(runInference("", null, null)).toBeNull();
56+
expect(runInference(null)).toBeNull();
57+
expect(runInference("")).toBeNull();
5858
});
5959

6060
it("flattens a vertical range of user prompts", () => {
6161
mockOkResponse("ok");
62-
runInference([["p1"], ["p2"]], null, null);
62+
runInference([["p1"], ["p2"]]);
6363
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
6464
expect(payload.contents[0].parts).toHaveLength(2);
6565
expect(payload.contents[0].parts[0].text).toBe("p1");
@@ -68,7 +68,7 @@ describe("runInference", () => {
6868

6969
it("encodes a valid drive link as inlineData", () => {
7070
mockOkResponse("ok");
71-
runInference("prompt", "https://drive.google.com/file/d/abc123/view", null);
71+
runInference("prompt", "https://drive.google.com/file/d/abc123/view");
7272
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
7373
expect(payload.contents[0].parts[1].inline_data).toEqual({
7474
mime_type: "application/pdf",
@@ -78,43 +78,43 @@ describe("runInference", () => {
7878

7979
it("filters out invalid drive links silently", () => {
8080
mockOkResponse("ok");
81-
runInference("prompt", "not-a-drive-link", null);
81+
runInference("prompt", "not-a-drive-link");
8282
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
8383
expect(payload.contents[0].parts).toHaveLength(1); // text only, no inline_data
8484
});
8585

86-
it("omits inlineData from payload when driveLinks is null", () => {
86+
it("omits inlineData from payload when driveLinks is omitted", () => {
8787
mockOkResponse("ok");
88-
runInference("prompt", null, null);
88+
runInference("prompt");
8989
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
9090
expect(payload.tools).toBeUndefined();
9191
expect(payload.contents[0].parts).toHaveLength(1);
9292
});
9393

9494
it("passes systemPrompt to the payload", () => {
9595
mockOkResponse("ok");
96-
runInference("prompt", null, "Be concise");
96+
runInference("prompt", undefined, "Be concise");
9797
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
9898
expect(payload.system_instruction.parts[0].text).toBe("Be concise");
9999
});
100100

101-
it("uses default system prompt when systemPrompt is null", () => {
101+
it("uses default system prompt when systemPrompt is omitted", () => {
102102
mockOkResponse("ok");
103-
runInference("prompt", null, null);
103+
runInference("prompt");
104104
const payload = JSON.parse((UrlFetchApp.fetch as jest.Mock).mock.calls[0][1].payload);
105105
expect(payload.system_instruction.parts[0].text).toBe("You are a helpful assistant.");
106106
});
107107

108108
it("returns an error string when invokeGemini throws", () => {
109109
mockFetchResponse({ error: { message: "quota exceeded" } });
110-
expect(runInference("prompt", null, null)).toBe("Error: quota exceeded");
110+
expect(runInference("prompt")).toBe("Error: quota exceeded");
111111
});
112112

113113
it("returns an error string when Drive fetch throws", () => {
114114
(DriveApp.getFileById as jest.Mock).mockImplementationOnce(() => {
115115
throw new Error("File not found");
116116
});
117-
expect(runInference("prompt", "https://drive.google.com/file/d/abc123/view", null)).toBe(
117+
expect(runInference("prompt", "https://drive.google.com/file/d/abc123/view")).toBe(
118118
"Error: File not found",
119119
);
120120
});

__tests__/utils.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
sampleRows,
1515
truncateText,
1616
flattenArg,
17+
resolveColumns,
1718
} from "../src/server/utils";
1819
import type { DriveFileInfo } from "../src/shared/types";
1920

@@ -225,3 +226,25 @@ describe("flattenArg", () => {
225226
expect(flattenArg(42)).toEqual(["42"]);
226227
});
227228
});
229+
230+
describe("resolveColumns", () => {
231+
it("returns indices for all found names", () => {
232+
expect(resolveColumns(["a", "b", "c"], ["a", "c"])).toEqual([0, 2]);
233+
});
234+
235+
it("returns -1 for names not in headers", () => {
236+
expect(resolveColumns(["a", "b"], ["c"])).toEqual([-1]);
237+
});
238+
239+
it("returns empty array for empty names list", () => {
240+
expect(resolveColumns(["a", "b"], [])).toEqual([]);
241+
});
242+
243+
it("returns -1 for all names when headers is empty", () => {
244+
expect(resolveColumns([], ["a"])).toEqual([-1]);
245+
});
246+
247+
it("preserves the order of the names argument", () => {
248+
expect(resolveColumns(["x", "y", "z"], ["z", "x"])).toEqual([2, 0]);
249+
});
250+
});
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Dynamic Column Mapping for Run AI — Design
2+
3+
**Date:** 2026-02-24
4+
**Status:** Approved
5+
6+
## Problem
7+
8+
`runBatchAI` currently requires exact hardcoded column headers (`source_drive`, `system_prompt`, `user_prompt`, `ai_inference`) defined in `CONFIG.COLUMNS`. Users cannot use the tool without renaming their columns to match. The `AIMode` ("TEXT" | "FILE") binary forces a choice between text and Drive file input, preventing mixed requests even though `runInference` already supports them natively.
9+
10+
## Goal
11+
12+
Replace the hardcoded column map with a sidebar-driven flow where users select their column mappings at execution time. Remove `AIMode`, `ColumnMap`, `ColumnConfig`, and `CONFIG.COLUMNS` entirely.
13+
14+
---
15+
16+
## Section 1: Types & Data Contract
17+
18+
### Remove from `types.ts`
19+
- `AIMode`
20+
- `ColumnMap`
21+
- `ColumnConfig`
22+
- `AppConfig.COLUMNS`
23+
24+
### Remove from `config.ts`
25+
- `CONFIG.COLUMNS`
26+
27+
### Add to `types.ts`
28+
29+
```ts
30+
export interface RunConfig {
31+
userPromptCols: string[]; // required; values concatenated as userTexts
32+
driveFileCols?: string[]; // optional; values passed as driveLinks
33+
systemPromptCol?: string; // optional; single column, value used as systemPrompt
34+
outputCol: string; // required; created if not found in headers
35+
rowRange?: { start: number; end: number }; // 1-based, inclusive; overrides sheet selection
36+
}
37+
```
38+
39+
Only `userPromptCols` and `outputCol` are required. Empty/undefined optional fields mean "not selected" — not an error.
40+
41+
---
42+
43+
## Section 2: `runInference` Signature Update
44+
45+
Optional columns should be represented as optional in code. Update `runInference` to use optional parameters and add explicit guards:
46+
47+
```ts
48+
export function runInference(
49+
userPrompts: unknown,
50+
driveLinks?: unknown,
51+
systemPrompt?: unknown,
52+
): string | null {
53+
const userTexts = flattenArg(userPrompts);
54+
if (userTexts.length === 0) return null;
55+
56+
try {
57+
const inlineData: GeminiInlineData[] = driveLinks !== undefined
58+
? flattenArg(driveLinks).filter(isValidDriveLink).map((link) => fetchAndEncodeFile(extractId(link)))
59+
: [];
60+
61+
return invokeGemini({
62+
systemPrompt: systemPrompt !== undefined ? flattenArg(systemPrompt)[0] : undefined,
63+
userTexts,
64+
inlineData: inlineData.length ? inlineData : undefined,
65+
});
66+
} catch (e) {
67+
return "Error: " + (e as Error).message;
68+
}
69+
}
70+
```
71+
72+
---
73+
74+
## Section 3: Sidebar & Server Interface
75+
76+
### New server function: `getSheetHeaders()`
77+
78+
Reads the first row of the active sheet and returns `string[]`. Called by the sidebar on load to populate column pickers. Must be exported from `index.ts` and have a global stub in the Rollup footer.
79+
80+
### Sidebar flow
81+
82+
"Run AI Inference" no longer dispatches via `runTool`. Clicking it reveals an inline config panel within the sidebar. On open, the panel calls `getSheetHeaders()` and renders column pickers once headers are returned.
83+
84+
**Config panel fields:**
85+
86+
| Field | UI control | Required |
87+
|---|---|---|
88+
| User prompt columns | Checkbox list | Yes (≥1) |
89+
| Drive file columns | Checkbox list | No |
90+
| System prompt column | Single-select dropdown (with empty/none option) | No |
91+
| Output column | Dropdown of existing headers + "New column..." option revealing a text input pre-filled with `ai_` | Yes |
92+
| Row range | Radio: "Use sheet selection" (default) / "Specify range" (start + end number inputs) ||
93+
94+
- If `getSheetHeaders()` returns `[]`: show "No columns found — add headers to your sheet first" and disable Run
95+
- **Run** collects values into a `RunConfig` and calls `google.script.run.runBatchAI(config)` directly (not via `runTool`)
96+
- **Cancel** collapses the panel back to the tool list
97+
98+
### Removals
99+
- `showSourceDialog` (function + `TOOLS` entry)
100+
- `handleDialogSelection`
101+
- `dialog.ts` and its `HTML_TEMPLATE` export
102+
103+
---
104+
105+
## Section 4: `runBatchAI` Implementation
106+
107+
### Signature
108+
109+
```ts
110+
export function runBatchAI(config: RunConfig): void
111+
```
112+
113+
### Steps
114+
115+
1. **Read headers**`sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]`
116+
2. **Resolve column indices**for each selected column name, find its index in the header row
117+
3. **Validate**any column the user explicitly selected must be present in the sheet headers. This includes optional fields if they were set:
118+
- `userPromptCols` missing`ui.alert` listing missing names, return early
119+
- `driveFileCols` selected but missingsame error
120+
- `systemPromptCol` selected but missingsame error
121+
- Unset optional fields (`driveFileCols` undefined/empty, `systemPromptCol` undefined) → silently skip
122+
4. **Resolve output column**if `outputCol` not found, append it as a new header in the next empty column; record that index
123+
5. **Determine row range**use `config.rowRange` if present; otherwise fall back to `sheet.getActiveRange()`
124+
6. **Loop over rows**for each row:
125+
- Collect values at all `userPromptCols` indices`userPrompts`
126+
- Collect values at all `driveFileCols` indices if set`driveLinks`
127+
- Collect value at `systemPromptCol` index if set`systemPrompt`
128+
- Call `runInference(userPrompts, driveLinks, systemPrompt)`
129+
- If result is `null`, skip; otherwise write to output column cell and flush
130+
131+
### Pure helper: `resolveColumns`
132+
133+
Extract column resolution into a testable pure function:
134+
135+
```ts
136+
function resolveColumns(headers: string[], names: string[]): number[]
137+
```
138+
139+
Returns an array of indices (same order as `names`). Returns `-1` for any name not found. Used inside `runBatchAI` for validation and index lookup.
140+
141+
---
142+
143+
## Section 5: Error Handling Summary
144+
145+
| Scenario | Behavior |
146+
|---|---|
147+
| `getSheetHeaders()` on empty sheet | Returns `[]`; sidebar disables Run |
148+
| Selected column not found at execution | `ui.alert` listing missing columns, return early |
149+
| Unset optional column | Silently skipped |
150+
| `outputCol` not found | Append as new column header, continue |
151+
| Per-row inference failure | Write `"Error: ..."` string to output cell |
152+
| Server throw from sidebar | Existing `withFailureHandler` surfaces message |
153+
154+
---
155+
156+
## Section 6: Testing
157+
158+
- **`runInference`**update existing tests to use optional param signature; no behavioral changes
159+
- **`resolveColumns`**new unit tests: all found, partial miss, empty input, empty headers
160+
- **`runBatchAI` / `getSheetHeaders()`**remain untested per existing pattern for SpreadsheetApp-coupled functions (see `docs/plans/2026-02-18-testing-coverage-design.md`)
161+
- **Sidebar**manual testing in GAS; no unit tests
162+
163+
---
164+
165+
## Files Touched
166+
167+
| File | Change |
168+
|---|---|
169+
| `src/shared/types.ts` | Remove `AIMode`, `ColumnMap`, `ColumnConfig`, `AppConfig.COLUMNS`; add `RunConfig` |
170+
| `src/server/config.ts` | Remove `COLUMNS` from `CONFIG` and `AppConfig` |
171+
| `src/server/inference.ts` | Update `runInference` to optional params with explicit guards |
172+
| `src/server/index.ts` | Replace `runBatchAI(mode)` with `runBatchAI(config)`; add `getSheetHeaders()`; remove `showSourceDialog`, `handleDialogSelection`; remove `dialog.ts` import |
173+
| `src/server/dialog.ts` | Delete |
174+
| `src/Sidebar.html` | Replace "Run AI Inference" button with inline config panel |
175+
| `rollup.config.js` | Add `getSheetHeaders` global stub; remove `handleDialogSelection`, `showSourceDialog` stubs |
176+
| `__tests__/inference.test.ts` | Update param signatures |
177+
| `__tests__/` | Add `resolveColumns` unit tests |

0 commit comments

Comments
 (0)