# Light

> Ask a question and get an answer strictly from your documents, with numbered citations or an honest refusal; web search only as an explicit second step.

Light is the document assistant of Subsidia: **retrieve, cite, compute, never invent**. One retrieval and one strictly grounded completion per question, no memory, no persona. If your documents do not contain the answer, Light says so plainly instead of answering from general knowledge. That refusal is the feature: an answer that is always confident cannot be trusted when it matters.

Light is reachable with an API key (scope `light`), so you can put it behind your own interface, an internal bot or an automation.

**One Light turn. The web step is a separate call that you make on purpose, never automatically.**

Flow: Question -> Scope to a dossier (named or given) -> Retrieve passages -> Strict grounded completion -> Answer with [n] citations, or refusal -> Optional: /light/web

## Behaviour you can rely on

| Situation | What Light does |
| --- | --- |
| The passages answer the question | An answer whose claims carry `[n]` markers; `sources` lists those passages with file, section and page. |
| The documents do not cover it | The `answer` states, in the language of the question, that the documents do not cover it ("Les documents ne couvrent pas cette question."), and may suggest how to reformulate or which document to add. It never answers from outside knowledge. |
| Several client files hold the same document and the question does not say which | Light does not guess. It returns a question back in `answer`, fills `ambiguity` with the candidate dossiers, uses no model and no question from your allowance. Ask again with `dossierId`, or name the client in the question. |
| Table questions (sums, counts over a spreadsheet) | Computed deterministically; the `precomputed` field shows the tool, its arguments and its result. |
| Retrieval itself failed | A `503`. This is an outage, not an answer: nothing was searched, so nothing is claimed or refused. |
| Personal data in the question | Masked before leaving toward an external provider; the categories are listed in `masked`. Local models are never masked against. |

> **INFO: No separate refusal flag**
> A refusal is a sentence in `answer`, not a boolean. Show it to the user as it is. If your automation has to branch on it, branch on `sources` being empty or on `ambiguity` being non-null, and keep a person in the loop for the rest.

## Endpoints

> **WARNING: Scope**
> Both routes need the `light` scope on a restricted key. They only read, so a `readonly` key may call them too. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md).

### POST /light/ask

Ask a question of the workspace documents.

Answers only from the documents of the workspace, with numbered citations. The server keeps no conversation: to hold a dialogue, send the previous turns in `history` yourself.

The response carries a `proofId`, the id of a signed [proof certificate](https://dev.subsidia.protypa.fr/docs/proofs.md) of this turn, except when Light asked a clarifying question (no model answered).

The route lives at `/light/ask` (no `/v1` prefix).

- **Authentication:** API key (Bearer or x-api-key)
- **Scopes:** `light`

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `question` | `string` | yes |  | The question. Trimmed; an empty value is a 400. |
| `history` | `object[]` | no |  | Previous turns of the conversation, so a follow-up ("and for the second lease?") is understood. |
| `history.role` | `string` | no |  | Author of the turn. One of: `user`, `assistant`. |
| `history.content` | `string` | no |  | Text of the turn. |
| `dossierId` | `string` | no |  | Collection id (a client file). Restricts retrieval to that dossier. Without it, Light infers the dossier when the question or the history names one. |
| `provider` | `string` | no |  | `auto`, `local`, or a provider id allowed in the workspace. An unknown, unconfigured or disallowed value is a 400 with a code; Light never silently switches provider. |

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/light/ask \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the notice period to terminate the lease?", "dossierId": "col_123"}'
```

_TypeScript_

```typescript
const res = await fetch('https://api.subsidia.protypa.fr/light/ask', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    question: 'What is the notice period to terminate the lease?',
    dossierId: 'col_123',
  }),
})
if (!res.ok) throw new Error('Light failed: ' + res.status)
const { answer, sources, proofId } = await res.json()
console.log(answer)
for (const s of sources) console.log('[' + s.ref + ']', s.sourceName, s.page ?? '', s.section ?? '')
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr/light/ask",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={"question": "What is the notice period to terminate the lease?", "dossierId": "col_123"},
)
res.raise_for_status()
data = res.json()
print(data["answer"])
for s in data["sources"]:
    print("[%d]" % s["ref"], s["sourceName"], s["page"], s["section"])
```

#### Responses

**200**: The answer and everything needed to cite it.

```json
{
  "answer": "The tenant may terminate at the end of each three-year period with six months' notice, by registered letter [1].",
  "sources": [
    {
      "ref": 1,
      "sourceId": "clx9k2m4p0002abcd",
      "sourceName": "contrat-bail-2025.pdf",
      "dossierId": "col_123",
      "dossierName": "Dupont SARL",
      "section": "Article 12 - Termination",
      "page": 6,
      "chunkId": "ck_8f31a0c2",
      "chunkPreview": "The tenant may terminate the lease at the end of each three-year period...",
      "similarity": 0.81,
      "pinnedByUser": false
    }
  ],
  "masked": [],
  "precomputed": null,
  "tokensUsed": 1210,
  "scope": { "dossiers": [{ "id": "col_123", "name": "Dupont SARL" }], "how": "chosen" },
  "ambiguity": null,
  "proofId": "pf_a059713a5f1c4e0b8d7a3c21e9b64f10"
}
```

**400**: `question` missing or empty, no workspace on the key, or an invalid `provider` (`provider_unknown` and the other provider policy codes, with a `message`).

```json
{ "error": "question is required" }
```

**503**: The search in the documents failed. Nothing was answered. Retry shortly.

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 400 |  | Empty question, or `provider` not allowed. |
| 401 |  | Missing or invalid key. |
| 402 | `QUESTION_LIMIT` | The workspace has no questions left. |
| 403 | `scope_denied` | The key lacks the `light` scope. |
| 429 | `rate_limited` | The key exceeded its own requests-per-minute limit. |
| 503 |  | Retrieval failed. |

#### Notes

`sources[].ref` matches the `[n]` markers in `answer`. `scope.how` is `named` when the dossier was inferred from the question, `chosen` when you passed `dossierId`. A clarifying turn has `ambiguity` set to `{ document, dossiers, more }`, `tokensUsed` 0 and `proofId` null. The answer text may contain internal grounding rail characters in some surfaces; strip any of the four characters U+27E6, U+27E7, U+27EC, U+27ED before displaying it in your own UI.

## Web fallback, only after a refusal

A web search takes the question outside the building, so it is never part of `/light/ask`. When Light has refused and the person agrees, call `/light/web` as a separate, explicit step:

- The query is **redacted before it leaves** (personal data, names of your dossiers), and the query actually sent is returned as `query` and written to the audit journal, so a firm can check what left the machine.
- The answer comes from the fetched pages only and is returned with the page URLs as `sources`. Never display it as if it came from the client's documents.
- It needs web search to be configured on the server; otherwise it answers `409`.

### POST /light/web

Answer from the web, as an explicit follow-up to a refusal.

Searches the web with a redacted query and answers from the pages found, with numbered citations to those pages. Call it only when the user has chosen to go to the web.

- **Authentication:** API key (Bearer or x-api-key)
- **Scopes:** `light`

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `question` | `string` | yes |  | The question that Light refused. Redacted server-side before the search. |
| `provider` | `string` | no |  | Same meaning as on `/light/ask`. |

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/light/web \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the legal minimum notice period for a commercial lease in France?"}'
```

_TypeScript_

```typescript
async function askLightThenWeb(question: string, userAgreesToWeb: () => Promise<boolean>) {
  const headers = {
    Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY,
    'Content-Type': 'application/json',
  }
  const doc = await fetch('https://api.subsidia.protypa.fr/light/ask', {
    method: 'POST', headers, body: JSON.stringify({ question }),
  }).then((r) => r.json())

  if (doc.sources.length > 0 || doc.ambiguity) return { origin: 'documents', ...doc }

  if (!(await userAgreesToWeb())) return { origin: 'documents', ...doc } // the refusal, as is

  const web = await fetch('https://api.subsidia.protypa.fr/light/web', {
    method: 'POST', headers, body: JSON.stringify({ question }),
  }).then((r) => r.json())
  return { origin: 'web', ...web } // label it as web in your UI
}
```

_Python_

```python
import os, requests

res = requests.post(
    "https://api.subsidia.protypa.fr/light/web",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={"question": "What is the legal minimum notice period for a commercial lease in France?"},
)
if res.status_code == 409:
    print("Web search is not configured on this server")
else:
    web = res.json()
    print(web["answer"], [s["url"] for s in web["sources"]])
```

#### Responses

**200**: The web answer. When the search finds nothing, `answer` is empty, `sources` is empty and `reachable` tells you whether the search engine could be reached at all.

```json
{
  "answer": "For a commercial lease the tenant may give notice at the end of each three-year period, six months in advance [1].",
  "sources": [
    {
      "ref": 1,
      "sourceName": "service-public.fr",
      "title": "Commercial lease: termination by the tenant",
      "url": "https://www.service-public.fr/professionnels-entreprises/vosdroits/F31125",
      "preview": "The tenant may terminate the lease at the end of each three-year period..."
    }
  ],
  "reachable": true,
  "query": "minimum notice period commercial lease France",
  "tokensUsed": 940
}
```

**400**: `question` missing or empty, or an invalid `provider`.

**409**: Web search is not configured on this server (`web_search_disabled`).

```json
{ "error": "web_search_disabled", "message": "Web search is not configured (SearXNG). Enable it in the server settings." }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 400 |  | Empty question, or `provider` not allowed. |
| 403 | `scope_denied` | The key lacks the `light` scope. |
| 409 | `web_search_disabled` | No web search engine is configured on the server. |

## Light, Gateway or knowledge/query?

| You want | Use |
| --- | --- |
| A cited answer or an honest refusal, no prompt to write | `POST /light/ask` |
| The passages only, to build your own prompt, panel or check | [`POST /v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md) |
| Retrieval inside an existing OpenAI or Anthropic client | Add `+cortex` to the model in the [Gateway](https://dev.subsidia.protypa.fr/docs/gateway.md); the passages are committed to the certificate. |
| Check an existing text against the documents, claim by claim | [The Verificateur](https://dev.subsidia.protypa.fr/docs/verify.md) |

## Frequently asked

**Does Light count against my questions?**

Yes: a Light turn that reaches the model is a question of your allowance, like any answer. A clarifying turn (`ambiguity`) uses no model and costs nothing. Ingestion never counts. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md).

**Can I keep a conversation?**

The server is stateless. Send the earlier turns in `history`. Pinned passages and saved workbooks are features of the app, not of this API.

**Why does Light refuse when I know the answer is in a file?**

Check the source is `ready` ([Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md)), that you did not restrict to the wrong `dossierId`, and run the same question through [`/v1/knowledge/query`](https://dev.subsidia.protypa.fr/docs/knowledge-query.md) to see which passages are retrieved. A refusal on a scan usually means OCR did not read it.
