Skip to content

Commit 0b7d1e4

Browse files
authored
Merge pull request #97 from propublica/develop
v4
2 parents e6014f4 + 4e58aea commit 0b7d1e4

71 files changed

Lines changed: 12409 additions & 791 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Thumbs.db
2323
# Gemini CLI internal
2424
.gemini/
2525

26+
# Jest cache (redirect from system temp for sandbox compatibility)
27+
.jest-cache/
28+
2629
# Git worktrees
2730
.worktrees/
2831

CLAUDE.md

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,13 @@ src/client/sidebar-entry.ts (thin init — instantiates all panels, creates Rou
132132
├── lockable-field.ts (LockableField — value + lock/unlock toggle; optional onUnlock callback)
133133
├── recipe-prep-cook.ts (RecipePrepCook — 4-state machine: idle/prepping/prep-complete/cooking)
134134
├── panel-loader.ts (PanelLoader — drives panel loading skeleton: progress bar, spinner, message)
135-
└── job-indicator.ts (JobIndicator — renders active/failed jobs to #job-strip; persists across navigation)
135+
├── job-indicator.ts (JobIndicator — renders active/failed jobs to #job-strip; persists across navigation)
136+
├── token-input.ts (TokenInput — searchable chip field for column selection; multi or single-select;
137+
│ supports includeNew for new column creation; use when item count is 8+ or items are
138+
│ dynamic headers. Prefer TagList when count is small and all options benefit from
139+
│ simultaneous display, e.g. the Tools section with ~5 fixed entries.)
140+
└── prompt-col-list.ts (PromptColList — ordered list of PromptColumnSpec rows; each row pairs a
141+
TokenInput column picker with a text/file kind toggle and reorder controls)
136142
└── src/shared/types.ts
137143
138144
src/client/google.d.ts (compile-time type stub for google.script.run — uses declare global{} pattern)
@@ -155,12 +161,35 @@ The Gemini tool system spans three layers. `ToolId` (a string union in `shared/t
155161

156162
`buildGeminiPayload` in `api.ts` resolves `ToolId[]` via `TOOL_REGISTRY`, splits by `kind`, and assembles both shapes into the `tools` array of the REST request.
157163

158-
**Propagation path:** `ConfigureAIRunPanel` (UI TagList) → `RunConfig.tools``runBatchAI``runInference(tools?)``invokeGemini``callGeminiAPI``buildGeminiPayload`. For recipes: `RecipePanel``PrepRecipeParams.tools` → server echoes back as `PrepRecipeResult.tools`assembled into `preppedRunConfig`.
164+
**Propagation path:** `ConfigureAIRunPanel` (UI TagList) → `RunConfig.tools``runBatchAI``runInference(tools?)``invokeGemini``callGeminiAPI``buildGeminiPayload`. For recipes: `RecipePanel``PrepRecipeParams.cols + inputValues` → server resolves `inputId` references and writes columns → `PrepRecipeResult.rowRange`client calls `buildRunTemplate(prepTemplate)` (derives `promptCols`/`systemPromptCol`/`outputCol` from `RecipeColumn.role`) merged with `definition.settings` and `rowRange` `preppedRunConfig`.
159165

160166
Source files use relative imports (e.g. `../shared/types`). The `@server/*` and `@shared/*` aliases are **Jest-only** (mapped in `jest.config.cjs`) and are not available in TypeScript source.
161167

162168
Only `index.ts` should reference Google Apps Script UI services (SpreadsheetApp, HtmlService, PropertiesService). On the client side, only `services.ts` calls `google.script.run` (wrapping each call as a Promise); `sidebar-entry.ts` is a thin init file that creates the Router and calls `router.start()`.
163169

170+
### Recipe System
171+
172+
Recipes are journalist-facing presets that automate column setup and launch a Run AI. Each `RecipeDefinition` in `src/client/recipes.ts` has four parts:
173+
174+
| Field | Type | Purpose |
175+
|-------|------|---------|
176+
| `inputs` | `RecipeInput[]` | Journalist-facing form fields rendered by `RecipePanel` |
177+
| `prepTemplate` | `RecipeColumn[]` | Column definitions sent to `prepRecipe()` on the server |
178+
| `settings` | `RecipeSettings?` | Non-column `RunConfig` fields (tools, markdown, grounding, etc.) |
179+
| Discovery fields | `id`, `name`, `icon`, `description`, `intro?` | Rendered in the recipes list and recipe header |
180+
181+
**`RecipeColumn`** (`src/client/types.ts`) = `PrepColSpec` + optional `role?: ColumnRole`. The `role` is client-only — `buildRunTemplate()` in `recipe.ts` maps roles to `promptCols`, `systemPromptCol`, and `outputCol` at cook time. Roles: `"file-prompt"` | `"text-prompt"` | `"system-prompt"` | `"output"`.
182+
183+
**`FillStrategy`** (`src/shared/types.ts`) controls how `prepRecipe()` populates each column:
184+
- `{ kind: "fill-value"; value: string }` — writes a static string to every row
185+
- `{ kind: "list-drive-folder"; inputId: string }` — lists files from the folder URL in `inputValues[inputId]`
186+
- `{ kind: "create-empty" }` — creates the column with no content
187+
- `{ kind: "template"; template: string }` — interpolates `{{inputId}}` placeholders; supports Mustache-style conditionals `{{#inputId}}...{{/inputId}}` (block is omitted when the input is empty)
188+
189+
**`RecipeInput.id`** must be camelCase or underscore_separated — no hyphens. The template interpolation regex uses `\w+` which does not match `-`.
190+
191+
**To add a new recipe:** add an entry to `RECIPES` in `src/client/recipes.ts`. No other files need changing unless you need a new `FillStrategy` kind.
192+
164193
### TypeScript Configuration
165194

166195
Two tsconfigs for two build environments:
@@ -256,3 +285,25 @@ Follows Google TypeScript Style Guide (enforced by ESLint + Prettier + pre-commi
256285
- Semicolons required, double quotes (Prettier), trailing commas
257286
- Explicit return types on functions (ESLint warning)
258287
- Prefix unused parameters with `_`
288+
289+
## CSS Conventions
290+
291+
All sidebar styles live in `src/client/sidebar.css`. Use CSS custom properties — don't hardcode values.
292+
293+
**Font sizes** — pick the closest token, don't use raw px:
294+
295+
| Token | Value | Typical use |
296+
|---|---|---|
297+
| `--font-size-100` | 11px | Labels, badges, small metadata |
298+
| `--font-size-200` | 12px | Helper text, tag chips, intro copy |
299+
| `--font-size-300` | 14px | Body text, inputs, buttons (default) |
300+
| `--font-size-400` | 16px | Panel titles |
301+
| `--font-size-500` | 18px | Icons |
302+
303+
**Font family** — browsers don't inherit `font-family` on form elements. Any new `input`, `button`, `textarea`, or `select` must explicitly set `font-family: var(--font-family)`.
304+
305+
**Colors** — prefer variables over hardcoded hex:
306+
- `--text-main` — primary text
307+
- `--text-secondary` — secondary/muted text
308+
- `--primary-blue` — interactive blue (`#1a73e8`)
309+
- `--border-color` — borders and dividers

__tests__/api.test.ts

Lines changed: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
(globalThis as any).UrlFetchApp = {
1111
fetch: jest.fn(),
12+
fetchAll: jest.fn(),
1213
};
1314

1415
(globalThis as any).PropertiesService = {
@@ -19,7 +20,12 @@
1920

2021
// ── Import after mocks ─────────────────────────────────────────
2122

22-
import { buildGeminiPayload, callGeminiAPI, invokeGemini } from "../src/server/api";
23+
import {
24+
buildGeminiPayload,
25+
callGeminiAPI,
26+
callGeminiAPIBatch,
27+
invokeGemini,
28+
} from "../src/server/api";
2329
import { CONFIG } from "../src/server/config";
2430
import type { GeminiRequest } from "../src/server/types";
2531

@@ -186,19 +192,12 @@ describe("buildGeminiPayload", () => {
186192
expect((payload.generationConfig as any).temperature).toBe(0.5);
187193
});
188194

189-
it("applies CONFIG.MAX_OUTPUT_TOKENS as default maxOutputTokens when no generationConfig is provided", () => {
195+
it("does not set maxOutputTokens when no generationConfig is provided", () => {
190196
const payload = buildGeminiPayload(baseReq);
191-
expect((payload.generationConfig as any).maxOutputTokens).toBe(CONFIG.MAX_OUTPUT_TOKENS);
197+
expect((payload.generationConfig as any).maxOutputTokens).toBeUndefined();
192198
});
193199

194-
it("applies CONFIG.MAX_OUTPUT_TOKENS as default when generationConfig omits maxOutputTokens", () => {
195-
const req: GeminiRequest = { ...baseReq, generationConfig: { temperature: 0.7 } };
196-
const payload = buildGeminiPayload(req);
197-
expect((payload.generationConfig as any).maxOutputTokens).toBe(CONFIG.MAX_OUTPUT_TOKENS);
198-
expect((payload.generationConfig as any).temperature).toBe(0.7);
199-
});
200-
201-
it("uses caller-supplied maxOutputTokens over CONFIG default", () => {
200+
it("passes through caller-supplied maxOutputTokens", () => {
202201
const req: GeminiRequest = { ...baseReq, generationConfig: { maxOutputTokens: 512 } };
203202
const payload = buildGeminiPayload(req);
204203
expect((payload.generationConfig as any).maxOutputTokens).toBe(512);
@@ -362,3 +361,135 @@ describe("invokeGemini", () => {
362361
expect(payload.contents[0].parts[1].inline_data.mime_type).toBe("application/pdf");
363362
});
364363
});
364+
365+
// ── callGeminiAPIBatch tests ───────────────────────────────────
366+
367+
function mockFetchAllResponses(bodies: unknown[]) {
368+
(UrlFetchApp.fetchAll as jest.Mock).mockReturnValue(
369+
bodies.map((body) => ({ getContentText: () => JSON.stringify(body) })),
370+
);
371+
}
372+
373+
describe("callGeminiAPIBatch", () => {
374+
beforeEach(() => jest.clearAllMocks());
375+
376+
it("returns one GeminiResponse per request", () => {
377+
mockFetchAllResponses([
378+
{ candidates: [{ content: { parts: [{ text: "Result A" }] } }] },
379+
{ candidates: [{ content: { parts: [{ text: "Result B" }] } }] },
380+
]);
381+
const reqs: GeminiRequest[] = [
382+
{ apiKey: "key", userParts: [{ text: "Q1" }] },
383+
{ apiKey: "key", userParts: [{ text: "Q2" }] },
384+
];
385+
const results = callGeminiAPIBatch(reqs);
386+
expect(results).toHaveLength(2);
387+
expect(results[0].text).toBe("Result A");
388+
expect(results[1].text).toBe("Result B");
389+
});
390+
391+
it("returns empty array for empty input", () => {
392+
expect(callGeminiAPIBatch([])).toEqual([]);
393+
expect(UrlFetchApp.fetchAll as jest.Mock).not.toHaveBeenCalled();
394+
});
395+
396+
it("maps a Gemini error response to an error text result (does not throw)", () => {
397+
mockFetchAllResponses([
398+
{ error: { message: "quota exceeded" } },
399+
{ candidates: [{ content: { parts: [{ text: "OK" }] } }] },
400+
]);
401+
const reqs: GeminiRequest[] = [
402+
{ apiKey: "key", userParts: [{ text: "Q1" }] },
403+
{ apiKey: "key", userParts: [{ text: "Q2" }] },
404+
];
405+
const results = callGeminiAPIBatch(reqs);
406+
expect(results[0].text).toMatch(/Error:/);
407+
expect(results[1].text).toBe("OK");
408+
});
409+
410+
it("maps a non-JSON response to an error text result without aborting the batch", () => {
411+
(UrlFetchApp.fetchAll as jest.Mock).mockReturnValue([
412+
{
413+
getResponseCode: () => 503,
414+
getContentText: () => "<html>Service Unavailable</html>",
415+
},
416+
{
417+
getContentText: () =>
418+
JSON.stringify({ candidates: [{ content: { parts: [{ text: "OK" }] } }] }),
419+
},
420+
]);
421+
const reqs: GeminiRequest[] = [
422+
{ apiKey: "key", userParts: [{ text: "Q1" }] },
423+
{ apiKey: "key", userParts: [{ text: "Q2" }] },
424+
];
425+
const results = callGeminiAPIBatch(reqs);
426+
expect(results[0].text).toMatch(/Error:.*503/);
427+
expect(results[1].text).toBe("OK");
428+
});
429+
430+
it("includes file_data parts in the request payload", () => {
431+
mockFetchAllResponses([{ candidates: [{ content: { parts: [{ text: "ok" }] } }] }]);
432+
const req: GeminiRequest = {
433+
apiKey: "key",
434+
userParts: [
435+
{ text: "Describe this file" },
436+
{
437+
file_data: {
438+
file_uri: "https://generativelanguage.googleapis.com/v1beta/files/abc",
439+
mime_type: "application/pdf",
440+
},
441+
},
442+
],
443+
};
444+
callGeminiAPIBatch([req]);
445+
const calls = (UrlFetchApp.fetchAll as jest.Mock).mock.calls[0][0];
446+
const payload = JSON.parse(calls[0].payload);
447+
expect(payload.contents[0].parts[1].file_data).toEqual({
448+
file_uri: "https://generativelanguage.googleapis.com/v1beta/files/abc",
449+
mime_type: "application/pdf",
450+
});
451+
});
452+
453+
it("uses modelName from request when provided", () => {
454+
mockFetchAllResponses([{ candidates: [{ content: { parts: [{ text: "ok" }] } }] }]);
455+
callGeminiAPIBatch([
456+
{ apiKey: "key", modelName: "gemini-1.5-pro", userParts: [{ text: "Q" }] },
457+
]);
458+
const calls = (UrlFetchApp.fetchAll as jest.Mock).mock.calls[0][0];
459+
expect(calls[0].url).toContain("gemini-1.5-pro");
460+
});
461+
462+
it("falls back to CONFIG.MODEL_NAME when modelName is omitted", () => {
463+
mockFetchAllResponses([{ candidates: [{ content: { parts: [{ text: "ok" }] } }] }]);
464+
callGeminiAPIBatch([{ apiKey: "key", userParts: [{ text: "Q" }] }]);
465+
const calls = (UrlFetchApp.fetchAll as jest.Mock).mock.calls[0][0];
466+
expect(calls[0].url).toContain(CONFIG.MODEL_NAME);
467+
});
468+
469+
it("returns 'No response.' when candidates are empty", () => {
470+
mockFetchAllResponses([{ candidates: [] }]);
471+
const results = callGeminiAPIBatch([{ apiKey: "key", userParts: [{ text: "Q" }] }]);
472+
expect(results[0].text).toBe("No response.");
473+
});
474+
475+
it("populates codePairs when executableCode and codeExecutionResult parts are present", () => {
476+
mockFetchAllResponses([
477+
{
478+
candidates: [
479+
{
480+
content: {
481+
parts: [
482+
{ executableCode: { language: "PYTHON", code: "print(42)" } },
483+
{ codeExecutionResult: { outcome: "OUTCOME_OK", output: "42\n" } },
484+
],
485+
},
486+
},
487+
],
488+
},
489+
]);
490+
const results = callGeminiAPIBatch([{ apiKey: "key", userParts: [{ text: "Q" }] }]);
491+
expect(results[0].codePairs).toHaveLength(1);
492+
expect(results[0].codePairs![0].code.code).toBe("print(42)");
493+
expect(results[0].codePairs![0].result.output).toBe("42\n");
494+
});
495+
});

0 commit comments

Comments
 (0)