diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d3767c..ea7b24f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,7 @@ applies to every space of this project. Security problems go through | `pnpm typecheck` | `tsc --noEmit` | | `pnpm build` | Typecheck + build `dist/` (ESM, CJS, rolled-up `.d.ts`) | | `pnpm build:site` | Build the docs site into `site/` (demo app + API reference) | +| `pnpm wiki:sync` | Push `docs/wiki/` to the GitHub wiki (maintainers only) | The build has one entry per public import path: `lib/index.ts` becomes `dist/ai-form-fill.*` (imported as `ai-form-fill`) and `lib/voice/index.ts` @@ -74,6 +75,16 @@ radio inputs) rather than the Radix-based shadcn `Select`/`Checkbox`/ `RadioGroup`: the library writes to native form controls, and a Radix widget with a hidden mirror input would not update visually. +## Wiki + +The [wiki](https://github.com/JDeffner/ai-form-fill/wiki) is authored in this repository, under +`docs/wiki/`. Edit the page files there and open a pull request like any other change; do not edit +the pages in the GitHub wiki UI, because the next sync overwrites them. After the pull request is +merged, a maintainer runs `pnpm wiki:sync`, which clones the wiki repository, replaces its Markdown +with `docs/wiki/*.md` and pushes. File names become page titles, so `Getting-Started.md` is the page +"Getting Started" and links between pages are `[Getting Started](Getting-Started)`. `_Sidebar.md` and +`_Footer.md` are the wiki's navigation. + ## Docs site `pnpm build:site` builds what GitHub Pages serves at diff --git a/README.md b/README.md index 59a2155..1e86194 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ OpenAI-compatible API. Zero runtime dependencies. | You want | Use | | -------------------------------- | ------------------------------------------------------------- | | A finished panel, no build step | A script tag plus `` | -| A finished panel inside your app | `import 'ai-form-fill/ui'` plus `` | +| A finished panel inside your app | `defineFormFillElement()` plus `` | | Your own interface | `createFormFill()`, `useFormFill()` or the `AIFormFill` class | The element is built on the controller, the controller is built on the class. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md new file mode 100644 index 0000000..12d29bd --- /dev/null +++ b/docs/wiki/Architecture.md @@ -0,0 +1,146 @@ +# Architecture + +This page is the orientation for contributors: the data flow, the layers on top of it, the domains, +the invariants, and how to extend the library. It mirrors `docs/ARCHITECTURE.md` in the repository. + +The library is small on purpose: zero runtime dependencies, one data flow, three domains. + +## Data flow + +``` +HTMLFormElement + | lib/form/analyze.ts getFormFields() + v +FieldInfo[] stable keys (name -> id -> field_), labels + | (label[for], wrapping label, aria-*, title), + | normalized options, checkbox/radio grouping + v +lib/prompt/build.ts buildExtractionPrompt() + buildFormSchema() + | prompt text JSON schema with enums + v +AIProvider.chat() lib/providers/* - standard wire formats + | (Ollama REST, OpenAI chat-completions) + v +raw model output + | lib/prompt/parse-response.ts parseModelResponse() + v +Record throws ResponseParseError on garbage + | lib/core/ai-form-fill.ts applyExtraction() loop, by field.key + v +lib/form/apply.ts applyFieldValue(): per-type coercion, exact option + | matching, strict ISO date validation, native + v prototype setters + input/change events +FillResult { filled(+previous), skipped(+reason), + unmatchedKeys, missingRequired, raw } +``` + +`extract()` is the same flow stopped one step early, at `Record`: everything above +the apply loop, nothing below it. The loop itself is `applyExtraction(data, fields)`, and +`fillForm()` is literally `extract()` followed by that call, so the two cannot drift. A review step +calls the two halves separately, with the user's edits in between. See +[Events, Results and Review](Events-Results-and-Review). + +`fillField` is the single-field variant of the same flow: `analyzeField` -> `buildFieldPrompt` -> +`chat` -> `applyFieldValue`. + +Every step reports itself as a bubbling `CustomEvent` on the form (`lib/core/events.ts`): +`aff:start` before the request, `aff:field-filled` per written field, `aff:done` with the +`FillResult`, `aff:error` when the extraction throws. Nothing in the library listens to them; they +exist so a UI does not have to wrap the calls. + +`lib/form/revert.ts` runs the flow backwards: `revertFill(result)` writes each `filled` entry's +`previous` value back through the same setters and events. + +`lib/core/controller.ts` sits on top of all of it. `createFormFill()` resolves a form, a text source +and a trigger, owns one `AIFormFill`, and turns the promise into an observable `idle` / `working` / +`done` / `error` snapshot with `cancel` and `undo`. It adds no DOM of its own, so UI layers build on +the snapshot instead of on the promise. + +## Layers + +``` + useFormFill() your own UI + lib/ui/element.ts lib/react/use-form-fill.ts + \ | / + \ | / + ------------ createFormFill() -------- + lib/core/controller.ts + | + new AIFormFill() + lib/core/ai-form-fill.ts + | + form engine + prompt/schema + providers + lib/form/ lib/prompt/ lib/providers/ + + createDictation() -> text -> any of the three layers above + lib/voice/dictation.ts +``` + +Each layer is a separate entry point, so nothing is paid for twice: + +| Entry point | Builds on | Contains | +| -------------------- | -------------- | ----------------------------------------- | +| `ai-form-fill` | - | Class, controller, form engine, providers | +| `ai-form-fill/ui` | the controller | The custom element, its markup and CSS | +| `ai-form-fill/react` | the controller | One hook, `react` stays external | +| `ai-form-fill/voice` | nothing | The Web Speech wrapper | + +The voice module is text-out only. It never calls the core, so the two are independent: dictation +produces text, and what happens with it is the caller's decision. + +## Domains + +| Directory | Responsibility | +| ---------------- | ----------------------------------------------------------------------------- | +| `lib/core/` | Public class, controller, events, types, typed errors, defaults | +| `lib/form/` | DOM work: reading (`analyze.ts`), writing (`apply.ts`), undo (`revert.ts`) | +| `lib/prompt/` | LLM-facing text and schema (`build.ts`), output parsing (`parse-response.ts`) | +| `lib/providers/` | Transport: base class, Ollama, OpenAI-compatible, shared HTTP helper | +| `lib/ui/` | The `` element and its stylesheet | +| `lib/react/` | The `useFormFill` hook | +| `lib/voice/` | Dictation through the Web Speech API | + +## Key invariants + +- **`FieldInfo.key` is the single source of field identity.** The prompt, the schema and the fill + loop all use it. Keys are derived once in `getFormFields` and deduplicated; never re-derive + identity elsewhere. +- **Option matching is exact** (value, then label, then case and whitespace insensitive equality). No + substring matching: `"male"` must never select `Female`. The schema's `enum` values make exact + model answers likely in the first place. +- **Application never throws for value problems.** `applyFieldValue` returns + `{ applied: false, reason }`; only provider and parse failures throw (`ProviderError`, + `ResponseParseError`). +- **Values go through native prototype setters** and `input` plus `change` events, so controlled + React, Vue and Svelte components observe the change. +- **Configuration is per-instance.** `AFF_DEFAULTS` is frozen; there is no global mutable state. +- **Schemas avoid provider-hostile constructs**: no `required` (extraction is optional per field), + and date and time constraints use `[0-9]` regex patterns, because grammar-based enforcers (Ollama, + llama.cpp) reject `\d` and would turn `format: 'time'` into RFC 3339 full-time with a UTC offset, + which HTML inputs reject. + +## Adding a provider + +Extend `AIProvider` (`lib/providers/provider.ts`): + +1. Implement `chat`, `listModels` and `isAvailable` using `requestJson` + (`lib/providers/http.ts`) for uniform timeout, abort and error handling. Failures must surface as + `ProviderError`. +2. Set `supportsStructured = true` if the service accepts a JSON schema, and translate + `ChatRequest.format` into the service's mechanism (`response_format` for OpenAI-style, top-level + `format` for Ollama). +3. Export it from `lib/index.ts`; add wire-format tests with a stubbed `fetch` (see + `tests/providers/`). + +A worked example is in [Providers](Providers). + +## Adding a field type + +1. `lib/form/analyze.ts`: make `analyzeField` extract whatever metadata the type needs (options, + multiplicity, and so on). +2. `lib/prompt/build.ts`: give it a schema shape and, if needed, a format hint in the extraction + prompt. +3. `lib/form/apply.ts`: add an application branch returning `applied` or `skipped` outcomes. +4. Add unit tests for all three layers plus an end-to-end case in `tests/core/ai-form-fill.test.ts`. + +See [Contributing](Contributing) for the workflow around these changes. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md new file mode 100644 index 0000000..2a0e750 --- /dev/null +++ b/docs/wiki/Contributing.md @@ -0,0 +1,116 @@ +# Contributing + +This page covers the local setup, the scripts, the test layout and the pull request rules. It +mirrors `CONTRIBUTING.md` in the repository. + +Bug reports, feature requests and pull requests are all welcome. The +[code of conduct](https://github.com/JDeffner/ai-form-fill/blob/main/CODE_OF_CONDUCT.md) applies to +every space of this project. Security problems go through +[the security policy](https://github.com/JDeffner/ai-form-fill/security/advisories/new), not through +a public issue. + +## Setup + +1. Install [Node.js](https://nodejs.org) 20 or newer and [pnpm](https://pnpm.io/installation). +2. Clone the repository and run `pnpm install`. +3. Optional, only for the integration tests and the local demo: install [Ollama](https://ollama.com) + and pull the default model. + + ```bash + ollama pull gemma3:4b + ``` + +4. Optional, only for the cloud provider demos: copy `.env.example` to `.env` and fill in the keys + you have. `.env` is gitignored. + +## Scripts + +| Script | What it does | +| ----------------------- | ------------------------------------------------------------- | +| `pnpm dev` | Vite dev server with the demo pages | +| `pnpm test` | Unit tests once, no network needed | +| `pnpm test:watch` | Unit tests in watch mode | +| `pnpm test:integration` | The Ollama integration suite, needs a running Ollama | +| `pnpm lint` | ESLint over the whole repository | +| `pnpm lint:fix` | ESLint with autofix | +| `pnpm format` | Prettier write | +| `pnpm format:check` | Prettier check, the CI mode | +| `pnpm typecheck` | `tsc --noEmit` | +| `pnpm build` | Typecheck and build `dist/` (ESM, CJS, rolled-up `.d.ts`) | +| `pnpm build:site` | Build the docs site into `site/` (demo app and API reference) | +| `pnpm wiki:sync` | Push `docs/wiki/` to the GitHub wiki (maintainers) | + +The build has one entry per public import path: `lib/index.ts` becomes `dist/ai-form-fill.*` and +`lib/voice/index.ts` becomes `dist/voice.*`. Add an entry in `vite.config.js` and an `exports` key in +`package.json` for every new one. + +## Tests + +- Unit tests live in `tests/` and run in a jsdom environment, so no local setup is required. + `document` and the DOM element classes are globals; reset state with `document.body.innerHTML = ''` + in a `beforeEach` if your test touches the DOM. +- The files in `tests/requirements/` trace to thesis requirements (FR-01 to FR-11). Keep the `FR-XX` + prefix in the file names, and update the tests when the API changes, but do not delete them. +- `tests/integration/ollama.integration.test.ts` talks to a real Ollama at + `http://localhost:11434` and **fails**, rather than skipping, when Ollama is unreachable. Run it + explicitly with `pnpm test:integration`. + +## Demo app + +`examples/` is a small React app with hash-routed pages in `examples/pages/`, styled with shadcn/ui. +The pages are Element, Controller, Voice, React hook, Advanced and Script tag. The last one links to +`examples/vanilla.html`, which is static, loads the built script-tag bundle by relative path and +imports nothing from `lib/`, so run `pnpm build` once before opening it. `examples/snippets/` holds +the Vue and Svelte snippets. + +The generated shadcn components live in `examples/components/ui/`, `components.json` configures the +shadcn CLI (`pnpm dlx shadcn@latest add `), and the `@/` alias points at `examples/`. All +of this is dev-only: the library build has no Tailwind or shadcn dependency, and the only entry that +touches React is `lib/react/`, where `react` is an optional peer dependency left external. + +Forms in the demos use native controls (`NativeSelect`, plain checkbox and radio inputs) rather than +the Radix-based shadcn `Select`, `Checkbox` and `RadioGroup`: the library writes to native form +controls, and a Radix widget with a hidden mirror input would not update visually. + +## Documentation + +- **The wiki** is authored in the repository, under `docs/wiki/`. Edit the page files there and open + a pull request like any other change; do not edit the pages in the GitHub wiki UI, because the next + sync overwrites them. After the pull request is merged, a maintainer runs `pnpm wiki:sync`, which + copies `docs/wiki/*.md` into the wiki repository and pushes it. File names become page titles, so + `Getting-Started.md` is the page "Getting Started" and links between pages are + `[Getting Started](Getting-Started)`. `_Sidebar.md` and `_Footer.md` are the wiki's navigation. +- **The API reference** is generated from the TSDoc comments by `pnpm build:site`. Public symbols + carry TSDoc, and the rolled-up `dist/*.d.ts` preserves it for consumers, so document every new + exported symbol. +- **The README** stays the short overview; the long guides belong in the wiki. + +## Docs site + +`pnpm build:site` builds what GitHub Pages serves at https://jdeffner.github.io/ai-form-fill/ into +`site/`, which is gitignored: + +1. `vite build -c vite.site.config.js` builds the demo app with the base path `/ai-form-fill/`. The + app is hash-routed, so it needs no rewrite rules. +2. `node scripts/build-site.mjs` copies `examples/vanilla.html` and the built + `dist/ai-form-fill.browser.js` into `site/examples/`. The page loads the bundle by relative path, + so the pair works under any base path. +3. `typedoc` generates the API reference from the four entry points into `site/api`, which the "Docs" + link in the demo header points at. In `pnpm dev` that link 404s until you have run + `pnpm build:site` once. + +`.github/workflows/pages.yml` runs this on every push to `main` and deploys the result. + +## Pull requests + +- Keep pull requests focused. Renames and moves go in their own commit so diffs stay reviewable. +- Before pushing, run + `pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build`. CI runs exactly + these, on Node 20 and 22. +- Add a line under `## Unreleased` in `CHANGELOG.md` for anything a user would notice. +- Public APIs carry TSDoc comments, so please document any new exported symbol. +- `dist/` is tracked in git, because Composer installs from the repository. Do not edit it by hand, + but do commit the rebuilt output when you change `lib/`: CI fails a pull request against `main` + whose `dist/` is stale. + +The layout of the source and the invariants a change has to keep are in [Architecture](Architecture). diff --git a/docs/wiki/Errors-and-Troubleshooting.md b/docs/wiki/Errors-and-Troubleshooting.md new file mode 100644 index 0000000..5a2597e --- /dev/null +++ b/docs/wiki/Errors-and-Troubleshooting.md @@ -0,0 +1,173 @@ +# Errors and Troubleshooting + +This page lists the error classes the library throws and the failures people actually hit, with the +fix for each. + +## Error classes + +| Class | Thrown when | Carries | +| -------------------- | ------------------------------------------------------ | ------------------------------ | +| `AFFError` | Base class; also configuration problems | `message` | +| `ProviderError` | Network failure, HTTP error status, or timeout | `provider`, `status?`, `cause` | +| `ResponseParseError` | The model output was empty, not JSON, or not an object | `raw` | + +```typescript +import { AFFError, ProviderError, ResponseParseError } from 'ai-form-fill'; + +try { + await aiForm.fillForm(form, text); +} catch (error) { + if (error instanceof ProviderError) { + console.error(`${error.provider} failed`, error.status, error.cause); + } else if (error instanceof ResponseParseError) { + console.error('Unusable model output:', error.raw); + } else if (error instanceof AFFError) { + console.error('Configuration problem:', error.message); + } +} +``` + +Catch `AFFError` to handle all of them at once. + +Two rules make the rest predictable: + +- **Per-field problems never throw.** They land in `FillResult.skipped` with a reason. +- **An abort you triggered is not an error.** A caller's `AbortSignal` rejection passes through + untouched, so cancelling is distinguishable from failing. The controller and the hook turn it into + a `null` result instead. + +`createFormFill` throws a plain `Error` when the form, source or trigger selector resolves to the +wrong kind of element, with the selector in the message. + +## The fill does nothing and the console shows a CORS error + +Ollama only answers browser requests from origins it knows. `http://localhost` works, your staging +domain does not. + +```bash +OLLAMA_ORIGINS=https://app.example.com ollama serve +``` + +On Windows, set the `OLLAMA_ORIGINS` environment variable and restart Ollama. Several origins are +comma-separated. Use `*` for local experiments only. The failure reaches your code as a +`ProviderError` saying the connection failed, because the browser hides the reason from JavaScript. + +## `ProviderError` with status 401 or 403 + +The request reached the service but was not authorised. + +- Through a proxy: the proxy did not add the key, the key is wrong, or the proxy requires your own + app's session and the request went out without it. +- Direct: `apiKey` is missing. In a browser, `apiKey` also needs `allowApiKeyInBrowser: true`, and + the constructor throws an `AFFError` before any request goes out otherwise. + +Check `error.status` and `error.provider` to tell the two apart, and try the same call with `curl` to +see the service's own message. + +## `ProviderError` with status 404 + +The base URL is wrong. The library appends `/chat/completions` and `/models`, so `baseUrl` must be +the prefix, not the full endpoint. `https://api.openai.com/v1` is right, +`https://api.openai.com/v1/chat/completions` is not. Trailing slashes are stripped for you. + +For a proxy: it must forward exactly those two paths. + +## "does not support chat" or a 400 from Ollama + +The model is not a chat model, or the local copy is incomplete. Pull it again: + +```bash +ollama pull gemma3:4b +ollama run gemma3:4b "hello" +``` + +Embedding models (`nomic-embed-text` and friends) have no chat endpoint and cannot be used. + +## The model ignores the schema + +Symptoms: keys that are not in the form, prose around the JSON, option values that were never +offered. + +- Check that the provider really enforces schemas. `supportsStructuredOutput()` has to be `true`, and + a custom provider must translate `ChatRequest.format` into the service's own mechanism. +- Use a bigger or newer model. Very small models drift on long input. +- Shorten the source text. A wall of unrelated text raises the chance of invented values. +- Add `data-aff-hint` to the fields it gets wrong; see [Form Fields and Hints](Form-Fields-and-Hints). + +Values that are not in the schema simply end up in `result.unmatchedKeys` and are never written, so +this is a quality problem, not a safety problem. + +## `ResponseParseError` + +The answer was empty, or it was not a JSON object. `error.raw` holds exactly what came back, which is +usually enough to see the cause: a refusal, a code fence around the JSON, or a truncated answer. + +Turn on `debug: true` to log the extracted data and the result, and try the same prompt against the +model directly. + +## The result is empty: nothing was filled + +Check in this order: + +1. `result.unmatchedKeys` is full. The model answered with its own key names. Give the fields proper + `name` attributes, because keys come from `name` first. +2. `result.skipped` is full. Read the reasons: they say exactly what was rejected. +3. Both are empty. The model found nothing in the text. Check `result.raw`. +4. `skipFilled` is on and the fields already had values, so nothing was requested at all. + +## Dates are skipped + +The reason is `invalid-date-format`. Values must be ISO (`YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, `HH:MM`, +`YYYY-MM`, `YYYY-Www`) and are validated against the calendar. A local format like `15.03.1990` is +rejected rather than guessed. Add a hint that tells the model what the source text's date style +means, for example "dates are written day first". + +## A select or radio group does not match + +The reason is `no-matching-option`. Matching is exact on the value, then on the label, then +case-insensitive on either. There is no substring matching, on purpose. + +- Give options human labels. `` matches both "de" and "Germany". +- An `