# Facts, contradictions and health

> Read the atomic facts Cortex extracted, review conflicts between documents, and track a health score for the knowledge base.

Search finds passages. Once a knowledge base holds hundreds of documents, three other questions matter: *what exactly do my documents assert*, *do two of them disagree*, and *can I still trust what comes out of this corpus*. Cortex answers them at ingestion time and exposes the results here, so a workflow can act on them instead of discovering a bad answer in front of a client.

**Where the three objects come from. Facts and QA pairs are produced during ingestion; contradictions are found by comparing new facts with facts from other sources.**

Flow: Source ingested -> Atomic facts (with provenance) -> Compared with other sources -> Contradiction (review queue) -> Resolved by a person -> Health score

> **WARNING: Scope and flags**
> These routes need the `knowledge` scope. Facts and contradictions exist only for sources ingested with `extractFacts` enabled (the default), and the health replay only has material when `generateQa` was enabled. See [Knowledge sources](https://dev.subsidia.protypa.fr/docs/knowledge-sources.md). Resolving a contradiction changes data, so a read-only key gets `403 read_only_key`.

## Facts

A **fact** is one atomic claim taken verbatim from a document (for example, the lease runs until 30 September 2028), with the source and typed entities it mentions (people, companies, dates...). Each fact has a status:

| Status | Meaning |
|---|---|
| `active` | Current and not in conflict. |
| `disputed` | Part of an open contradiction with a fact from another source. |
| `stale` | Its validity window (`validUntil`) has passed. An hourly sweep moves expired facts here and fires the `knowledge.stale` webhook. |
| `retired` | Set aside when a contradiction was resolved in favour of the other fact. |

### GET /v1/knowledge/facts

List extracted facts with provenance.

Newest first, at most 200 per call, with no pagination: narrow with the filters instead of fetching everything. Use it to build a structured view of a client file, to feed a downstream system with sourced values, or to find facts that went stale.

- **Authentication:** API key
- **Scopes:** `knowledge`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `status` | `string` | no |  | Only facts in this status. One of: `active`, `disputed`, `stale`, `retired`. |
| `sourceId` | `string` | no |  | Only facts extracted from this source. |
| `q` | `string` | no |  | Case-insensitive substring match on the fact statement. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/knowledge/facts?status=stale&q=lease" \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const url = new URL('https://api.subsidia.protypa.fr/v1/knowledge/facts')
url.searchParams.set('status', 'stale')
const { facts } = await fetch(url, {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
}).then((r) => r.json())
for (const f of facts) console.log(f.statement, f.validUntil, f.source.name)
```

_Python_

```python
import os, requests

facts = requests.get(
    "https://api.subsidia.protypa.fr/v1/knowledge/facts",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    params={"status": "stale"},
).json()["facts"]
```

#### Responses

**200**: The facts.

```json
{
  "facts": [
    {
      "id": "fct_41ac9e",
      "statement": "The commercial lease runs until 30 September 2028.",
      "confidence": 0.92,
      "status": "active",
      "validFrom": "2025-10-01T00:00:00.000Z",
      "validUntil": "2028-09-30T00:00:00.000Z",
      "createdAt": "2026-10-09T08:14:02.000Z",
      "source": { "id": "clx9k2m4p0002abcd", "name": "contrat-bail-2025.pdf" },
      "entities": [{ "id": "ent_77", "name": "Dupont SARL", "type": "organization" }]
    }
  ]
}
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 401 |  | Missing or invalid key. |
| 403 | `scope_denied` | The key lacks the `knowledge` scope. |

#### Notes

`validFrom` and `validUntil` are `null` when the document states no validity window. `confidence` is the extractor self-assessment, 0 to 1.

## Contradictions

When new facts arrive, each is compared with the active facts of **other** sources (conflicts inside a single document are mostly extraction noise and are ignored). Candidate pairs are proposed by embedding similarity, then judged by a model, which gives a one-line explanation and a severity. A confirmed conflict becomes a contradiction in the review queue, both facts become `disputed`, and the `knowledge.contradiction_detected` webhook fires.

The judge fails open: if it is unavailable, no contradiction is recorded rather than a doubtful one.

### GET /v1/knowledge/contradictions

List contradictions between documents.

Newest first, at most 100. Use `?status=open` as your review queue: each entry shows the two facts side by side with the document each comes from, so a reviewer can decide in one look.

- **Authentication:** API key
- **Scopes:** `knowledge`

#### Query parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `status` | `string` | no |  | Only contradictions in this state. Omit for all. One of: `open`, `resolved`, `dismissed`. |

#### Request examples

_curl_

```bash
curl "https://api.subsidia.protypa.fr/v1/knowledge/contradictions?status=open" \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const { contradictions } = await fetch(
  'https://api.subsidia.protypa.fr/v1/knowledge/contradictions?status=open',
  { headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY } },
).then((r) => r.json())
for (const c of contradictions) {
  console.log(c.severity, c.factA.source.name, 'vs', c.factB.source.name, '-', c.explanation)
}
```

_Python_

```python
import os, requests

open_items = requests.get(
    "https://api.subsidia.protypa.fr/v1/knowledge/contradictions",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    params={"status": "open"},
).json()["contradictions"]
```

#### Responses

**200**: The contradictions.

```json
{
  "contradictions": [
    {
      "id": "ctr_5d20",
      "explanation": "The two documents give different notice periods for terminating the lease.",
      "severity": "high",
      "status": "open",
      "resolution": null,
      "createdAt": "2026-10-09T08:14:09.000Z",
      "resolvedAt": null,
      "factAId": "fct_41ac9e",
      "factBId": "fct_9b13d0",
      "factA": { "id": "fct_41ac9e", "statement": "Notice period: six months.", "status": "disputed", "source": { "id": "clx9k2m4p0002abcd", "name": "contrat-bail-2025.pdf" } },
      "factB": { "id": "fct_9b13d0", "statement": "Notice period: three months.", "status": "disputed", "source": { "id": "clx9k2m4p0007efgh", "name": "avenant-2026.docx" } }
    }
  ]
}
```

#### Notes

`severity` is `low`, `medium` or `high`.

### POST /v1/knowledge/contradictions/:id/resolve

Record the decision on an open contradiction.

Resolution is a human decision, so the API only records it and updates the two facts accordingly. Only **open** contradictions can be resolved: resolving twice returns 404.

| `action` | Fact A | Fact B | Contradiction becomes |
|---|---|---|---|
| `keep_a` | `active` | `retired` | `resolved` |
| `keep_b` | `retired` | `active` | `resolved` |
| `both` | `active` | `active` | `resolved` (both are true, for example different periods) |
| `dismiss` | unchanged | unchanged | `dismissed` (false alarm) |

The `resolution` field stores the action, followed by your note when you give one (500 characters kept).

- **Authentication:** API key
- **Scopes:** `knowledge`

#### Path parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `id` | `string` | yes |  | Contradiction id. |

#### Request body

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `action` | `string` | yes |  | The decision. One of: `keep_a`, `keep_b`, `both`, `dismiss`. |
| `note` | `string` | no |  | Why, for the record. Appended to `resolution`. |

#### Request examples

_curl_

```bash
curl -X POST https://api.subsidia.protypa.fr/v1/knowledge/contradictions/ctr_5d20/resolve \
  -H "Authorization: Bearer $SUBSIDIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action": "keep_b", "note": "The 2026 amendment supersedes the lease."}'
```

_TypeScript_

```typescript
const { contradiction } = await fetch(
  'https://api.subsidia.protypa.fr/v1/knowledge/contradictions/' + id + '/resolve',
  {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ action: 'keep_b', note: 'The 2026 amendment supersedes the lease.' }),
  },
).then((r) => r.json())
```

_Python_

```python
import os, requests

contradiction = requests.post(
    "https://api.subsidia.protypa.fr/v1/knowledge/contradictions/" + contradiction_id + "/resolve",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
    json={"action": "keep_b", "note": "The 2026 amendment supersedes the lease."},
).json()["contradiction"]
```

#### Responses

**200**: The updated contradiction.

```json
{
  "contradiction": {
    "id": "ctr_5d20",
    "status": "resolved",
    "resolution": "keep_b: The 2026 amendment supersedes the lease.",
    "resolvedAt": "2026-10-09T09:02:31.000Z"
  }
}
```

**400**: `action` missing or not one of the four values.

**404**: No **open** contradiction with this id in the workspace (unknown, already resolved, or another workspace).

```json
{ "error": "Open contradiction not found" }
```

#### Errors

| Status | Code | When |
| --- | --- | --- |
| 400 |  | Invalid or missing `action`. |
| 403 | `read_only_key` | The key is read-only. |
| 404 |  | Not found or not open. |

#### Notes

Resolving does not delete either source. Retired facts stay readable with `status=retired`.

## Health

The health score answers a question every team eventually asks: *is this corpus still in good shape?* It blends two measurements:

- **Answerable rate (70 percent).** At ingestion Cortex writes questions whose answer is in a known passage. Replaying them through retrieval shows how many are still found: a drop means documents were added that drown the old ones, or a source was damaged. The replay uses embeddings and full-text search only, so it costs no model call.
- **Hygiene (30 percent).** Open contradictions (up to 50 percent penalty), stale facts (up to 25 percent) and failed sources (up to 25 percent).

The score is an integer from 0 to 100, or `null` when there is nothing to measure. With no replayed question yet, it falls back to the hygiene rate alone.

### GET /v1/knowledge/health

Get the health score and its components.

Computed on read from the current state, so it always reflects open contradictions and failed sources right now. The answerable part reflects the last replay (see below), which also runs by itself once a day.

- **Authentication:** API key
- **Scopes:** `knowledge`

#### Request examples

_curl_

```bash
curl https://api.subsidia.protypa.fr/v1/knowledge/health -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const health = await fetch('https://api.subsidia.protypa.fr/v1/knowledge/health', {
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
}).then((r) => r.json())
if (health.score !== null && health.score < 80) console.warn('Knowledge base needs attention', health.hygiene)
```

_Python_

```python
import os, requests

health = requests.get(
    "https://api.subsidia.protypa.fr/v1/knowledge/health",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()
print(health["score"], health["hygiene"]["openContradictions"])
```

#### Responses

**200**: The health object.

```json
{
  "score": 88,
  "answerable": { "rate": 0.94, "passed": 47, "failed": 3, "total": 50, "lastCheckedAt": "2026-10-09T03:00:12.000Z" },
  "hygiene": { "rate": 0.75, "openContradictions": 2, "staleFacts": 1, "disputedFacts": 4, "failedSources": 0 },
  "corpus": { "sources": 38, "chunks": 1204, "facts": 612, "entities": 187, "qaPairs": 50 }
}
```

#### Notes

`answerable.rate` is `null` until a replay has checked at least one question.

### POST /v1/knowledge/health/replay

Re-run the health questions now.

Runs every stored question (up to 200 per call) through retrieval and records pass or fail. A question passes when its original passage is in the top 5, or when its source ranks in the top 3. Use it right after a large import or a deletion to see the effect at once instead of waiting for the daily run. Returns the replay summary and the refreshed health.

- **Authentication:** API key
- **Scopes:** `knowledge`

#### Request examples

_curl_

```bash
curl -X POST https://api.subsidia.protypa.fr/v1/knowledge/health/replay -H "Authorization: Bearer $SUBSIDIA_API_KEY"
```

_TypeScript_

```typescript
const { replay, health } = await fetch('https://api.subsidia.protypa.fr/v1/knowledge/health/replay', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY },
}).then((r) => r.json())
console.log(replay.passed + '/' + replay.total, 'questions still answerable; score', health.score)
```

_Python_

```python
import os, requests

data = requests.post(
    "https://api.subsidia.protypa.fr/v1/knowledge/health/replay",
    headers={"Authorization": "Bearer " + os.environ["SUBSIDIA_API_KEY"]},
).json()
print(data["replay"], data["health"]["score"])
```

#### Responses

**200**: The replay result and the new health.

```json
{
  "replay": { "total": 50, "passed": 47, "failed": 3 },
  "health": { "score": 88, "answerable": { "rate": 0.94, "passed": 47, "failed": 3, "total": 50, "lastCheckedAt": "2026-10-09T09:10:44.000Z" } }
}
```

#### Notes

The replay is synchronous and proportional to the number of stored questions. Do not call it in a tight loop.

## Use it in a workflow

1. **React to events**

   Subscribe to `knowledge.contradiction_detected` and `knowledge.stale` [webhooks](https://dev.subsidia.protypa.fr/docs/webhooks.md) so a person is told when a conflict or an expired fact appears, rather than polling.

2. **Keep a review queue**

   List `?status=open` contradictions in your own tool, show the two facts and their documents, and call `resolve` with the reviewer decision and a note. The note is your audit trail.

3. **Gate on health**

   Before a batch job that relies on the corpus (a monthly report, a client mailing), read `GET /v1/knowledge/health` and stop or warn below your own threshold.

4. **Verify what leaves the building**

   Facts tell you what the documents assert; the [Verificateur](https://dev.subsidia.protypa.fr/docs/verify.md) checks a text against them claim by claim.

## Frequently asked

**Why does a document that I know is consistent show a contradiction?**

Two documents can legitimately differ (an amendment, two periods). Resolve with `both`, or `dismiss` if it is a false alarm. Only conflicts across different sources are raised.

**Are retired facts used in answers?**

Retired facts are the losing side of a contradiction you resolved. They stay readable for history; filter `status=active` when you want the current state. Fact status does not delete the source passages, which remain searchable until you delete the source.

**Why is the score null?**

There is nothing to measure yet: no source, or sources without health questions. Ingest a source with `generateQa` enabled and run a replay.
