Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions __tests__/inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,17 @@ describe("runInference", () => {

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

it("returns null when userPrompts flattens to empty", () => {
expect(runInference(null, null, null)).toBeNull();
expect(runInference("", null, null)).toBeNull();
expect(runInference(null)).toBeNull();
expect(runInference("")).toBeNull();
});

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

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

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

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

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

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

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

it("returns an error string when Drive fetch throws", () => {
(DriveApp.getFileById as jest.Mock).mockImplementationOnce(() => {
throw new Error("File not found");
});
expect(runInference("prompt", "https://drive.google.com/file/d/abc123/view", null)).toBe(
expect(runInference("prompt", "https://drive.google.com/file/d/abc123/view")).toBe(
"Error: File not found",
);
});
Expand Down
23 changes: 23 additions & 0 deletions __tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
sampleRows,
truncateText,
flattenArg,
resolveColumns,
} from "../src/server/utils";
import type { DriveFileInfo } from "../src/shared/types";

Expand Down Expand Up @@ -225,3 +226,25 @@ describe("flattenArg", () => {
expect(flattenArg(42)).toEqual(["42"]);
});
});

describe("resolveColumns", () => {
it("returns indices for all found names", () => {
expect(resolveColumns(["a", "b", "c"], ["a", "c"])).toEqual([0, 2]);
});

it("returns -1 for names not in headers", () => {
expect(resolveColumns(["a", "b"], ["c"])).toEqual([-1]);
});

it("returns empty array for empty names list", () => {
expect(resolveColumns(["a", "b"], [])).toEqual([]);
});

it("returns -1 for all names when headers is empty", () => {
expect(resolveColumns([], ["a"])).toEqual([-1]);
});

it("preserves the order of the names argument", () => {
expect(resolveColumns(["x", "y", "z"], ["z", "x"])).toEqual([2, 0]);
});
});
177 changes: 177 additions & 0 deletions docs/plans/2026-02-24-dynamic-column-mapping-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Dynamic Column Mapping for Run AI — Design

**Date:** 2026-02-24
**Status:** Approved

## Problem

`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.

## Goal

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.

---

## Section 1: Types & Data Contract

### Remove from `types.ts`
- `AIMode`
- `ColumnMap`
- `ColumnConfig`
- `AppConfig.COLUMNS`

### Remove from `config.ts`
- `CONFIG.COLUMNS`

### Add to `types.ts`

```ts
export interface RunConfig {
userPromptCols: string[]; // required; values concatenated as userTexts
driveFileCols?: string[]; // optional; values passed as driveLinks
systemPromptCol?: string; // optional; single column, value used as systemPrompt
outputCol: string; // required; created if not found in headers
rowRange?: { start: number; end: number }; // 1-based, inclusive; overrides sheet selection
}
```

Only `userPromptCols` and `outputCol` are required. Empty/undefined optional fields mean "not selected" — not an error.

---

## Section 2: `runInference` Signature Update

Optional columns should be represented as optional in code. Update `runInference` to use optional parameters and add explicit guards:

```ts
export function runInference(
userPrompts: unknown,
driveLinks?: unknown,
systemPrompt?: unknown,
): string | null {
const userTexts = flattenArg(userPrompts);
if (userTexts.length === 0) return null;

try {
const inlineData: GeminiInlineData[] = driveLinks !== undefined
? flattenArg(driveLinks).filter(isValidDriveLink).map((link) => fetchAndEncodeFile(extractId(link)))
: [];

return invokeGemini({
systemPrompt: systemPrompt !== undefined ? flattenArg(systemPrompt)[0] : undefined,
userTexts,
inlineData: inlineData.length ? inlineData : undefined,
});
} catch (e) {
return "Error: " + (e as Error).message;
}
}
```

---

## Section 3: Sidebar & Server Interface

### New server function: `getSheetHeaders()`

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.

### Sidebar flow

"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.

**Config panel fields:**

| Field | UI control | Required |
|---|---|---|
| User prompt columns | Checkbox list | Yes (≥1) |
| Drive file columns | Checkbox list | No |
| System prompt column | Single-select dropdown (with empty/none option) | No |
| Output column | Dropdown of existing headers + "New column..." option revealing a text input pre-filled with `ai_` | Yes |
| Row range | Radio: "Use sheet selection" (default) / "Specify range" (start + end number inputs) | — |

- If `getSheetHeaders()` returns `[]`: show "No columns found — add headers to your sheet first" and disable Run
- **Run** collects values into a `RunConfig` and calls `google.script.run.runBatchAI(config)` directly (not via `runTool`)
- **Cancel** collapses the panel back to the tool list

### Removals
- `showSourceDialog` (function + `TOOLS` entry)
- `handleDialogSelection`
- `dialog.ts` and its `HTML_TEMPLATE` export

---

## Section 4: `runBatchAI` Implementation

### Signature

```ts
export function runBatchAI(config: RunConfig): void
```

### Steps

1. **Read headers** — `sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]`
2. **Resolve column indices** — for each selected column name, find its index in the header row
3. **Validate** — any column the user explicitly selected must be present in the sheet headers. This includes optional fields if they were set:
- `userPromptCols` missing → `ui.alert` listing missing names, return early
- `driveFileCols` selected but missing → same error
- `systemPromptCol` selected but missing → same error
- Unset optional fields (`driveFileCols` undefined/empty, `systemPromptCol` undefined) → silently skip
4. **Resolve output column** — if `outputCol` not found, append it as a new header in the next empty column; record that index
5. **Determine row range** — use `config.rowRange` if present; otherwise fall back to `sheet.getActiveRange()`
6. **Loop over rows** — for each row:
- Collect values at all `userPromptCols` indices → `userPrompts`
- Collect values at all `driveFileCols` indices if set → `driveLinks`
- Collect value at `systemPromptCol` index if set → `systemPrompt`
- Call `runInference(userPrompts, driveLinks, systemPrompt)`
- If result is `null`, skip; otherwise write to output column cell and flush

### Pure helper: `resolveColumns`

Extract column resolution into a testable pure function:

```ts
function resolveColumns(headers: string[], names: string[]): number[]
```

Returns an array of indices (same order as `names`). Returns `-1` for any name not found. Used inside `runBatchAI` for validation and index lookup.

---

## Section 5: Error Handling Summary

| Scenario | Behavior |
|---|---|
| `getSheetHeaders()` on empty sheet | Returns `[]`; sidebar disables Run |
| Selected column not found at execution | `ui.alert` listing missing columns, return early |
| Unset optional column | Silently skipped |
| `outputCol` not found | Append as new column header, continue |
| Per-row inference failure | Write `"Error: ..."` string to output cell |
| Server throw from sidebar | Existing `withFailureHandler` surfaces message |

---

## Section 6: Testing

- **`runInference`** — update existing tests to use optional param signature; no behavioral changes
- **`resolveColumns`** — new unit tests: all found, partial miss, empty input, empty headers
- **`runBatchAI` / `getSheetHeaders()`** — remain untested per existing pattern for SpreadsheetApp-coupled functions (see `docs/plans/2026-02-18-testing-coverage-design.md`)
- **Sidebar** — manual testing in GAS; no unit tests

---

## Files Touched

| File | Change |
|---|---|
| `src/shared/types.ts` | Remove `AIMode`, `ColumnMap`, `ColumnConfig`, `AppConfig.COLUMNS`; add `RunConfig` |
| `src/server/config.ts` | Remove `COLUMNS` from `CONFIG` and `AppConfig` |
| `src/server/inference.ts` | Update `runInference` to optional params with explicit guards |
| `src/server/index.ts` | Replace `runBatchAI(mode)` with `runBatchAI(config)`; add `getSheetHeaders()`; remove `showSourceDialog`, `handleDialogSelection`; remove `dialog.ts` import |
| `src/server/dialog.ts` | Delete |
| `src/Sidebar.html` | Replace "Run AI Inference" button with inline config panel |
| `rollup.config.js` | Add `getSheetHeaders` global stub; remove `handleDialogSelection`, `showSourceDialog` stubs |
| `__tests__/inference.test.ts` | Update param signatures |
| `__tests__/` | Add `resolveColumns` unit tests |
Loading