# API — 참조 서버

    python -m service --corpus corpus/tenants --port 8000

`--corpus` 아래의 **디렉터리 하나가 테넌트 하나**다.

**서버는 뜻 검색을 기본으로 켠다** (ADR-0027 ②) — `models/e5-small` 에 모델이
있으면 켜고, 없으면 **켜지 못했다고 말한 뒤** 낱말 검색으로 돈다.
엔진(`python -m noda.search`)과 점수판은 **꺼짐 그대로**다.
`NODA_VECTORS=0` 으로 서버에서도 끌 수 있다.

**「찾기」만 더 좋다** (SC). 켜면 `/search` 는 확실히 낫지만 — 새 질문 50건에서
낱말 49/50 · 뜻 벡터 50/50 (RU) — **`/answer` 의 발췌는 거의 안 움직인다**:
개발용 326문항에서 고친 4건 · 깨진 3건 · 순 +1건이다. 벡터는 조각을
**가져다 놓지만** 발췌기는 여전히 낱말 점수로 줄을 고른다. `llm: true` 로
게이트웨이를 쓰면 이 자리는 달라진다 (ADR-0018).

```
corpus/tenants/
  갑/공고.hwpx  규정.pdf
  을/계약서.docx
```

## 요청

| | | |
|---|---|---|
| `GET /` | — | **개발자 홈** — 무엇을 하는 엔진인지 · 30초 빠른 시작 · 세 길 · 점수판 숫자 (W446) |
| `GET /demo` | — | **데모 한 장** — 무엇이 들어와 있나 · 물어보기 · 근거 (TF) |
| `GET /docs/<이름>.md` | — | 저장소의 문서 그대로 (`api`·`rag`·`schema`·`deploy`·`install`·`brief`) — 폐쇄망에서도 계약을 읽는다 |
| `GET /health` | — | 살아 있는가 · 테넌트 목록 |
| `GET/POST /documents` | `{tenant, principal?}` | **무엇이 들어와 있고 무엇을 못 읽었나** · 부르면 지금 읽어 둔다 |
| `POST /search` | `{tenant, query, limit?, principal?}` | 조각 + 인용 근거 |
| `POST /answer` | `{tenant, question, limit?, llm?, principal?}` | 답 + 줄마다 인용. **`llm: true` 면 게이트웨이를 거친다** |
| `GET/POST /view` | `{tenant, question, limit?, llm?, principal?}` | **근거를 원문 위에 칠한 화면** (HTML) |
| `POST /parse` | 파일의 바이트 + `X-Filename` (또는 `{filename, data_base64}`) | **파일 하나 → 계약의 JSON** (W441) |
| `POST /chunk` | 같음 · `?budget=` | **파일 하나 → 문서 + 조각** |
| `POST /context` | `{tenant, query, budget?, limit?, principal?}` | **LLM 에게 넘길 근거 묶음** — 번호·파일·쪽·제목 경로 · 글자 예산 (W444) |
| `POST /verify` | `{tenant, answer, sources?, citations?, principal?}` | **소비자의 답을 근거에 대고 본다** — 근거에 없는 숫자 · 조각에 없는 인용 · 없는 번호를 자리로 (W449) |
| `POST /documents/upload` | `?tenant=` + 파일 바이트 + `X-Filename` (또는 `{tenant, filename, data_base64, replace?}`) | **테넌트에 문서를 넣고 바로 읽는다** — 키가 있는 서버만 |
| `DELETE /documents` | `?tenant=&filename=` | 테넌트에서 뺀다 — 키가 있는 서버만 |
| `GET /openapi.json` | — | **OpenAPI 3 명세** — 클라이언트를 만들 때 먼저 찾는 것 (W447) |
| `GET /try` · `POST /try/parse` | 파일 바이트 + `X-Filename` | **체험** — 키 없이 파일 하나를 올려 읽은 글·표·서식 칸·붙임·「확인할 자리」를 본다. `{summary, document, chunks}` · 저장 안 함 · IP 마다 한 시간 20번 · 10MB · 한 번에 한 건 · 60초 (넘으면 422 · W454) |
| `POST /mcp` | JSON-RPC 2.0 (MCP Streamable HTTP) | **에이전트가 도구로 부른다** — 읽기·목록·찾기·근거·검증·넣기·빼기 일곱 도구가 위의 길을 그대로 부른다 · `docs/mcp.md` (W460) |
| `GET /usage` | — | **소비자별 사용량** — `{이름: {길: {calls, errors, bytes_in, ms}}}` · 키가 있는 서버만 (W447) |

**요청 제한** (W448) — 둘 다 기본은 꺼짐이다. `NODA_RATE_LIMIT=120` 이면 **소비자마다 분당 120 요청**이고 넘으면 `429` 와
`Retry-After`(초)가 온다 (열린 길은 안 센다 · 최근 60초의 미끄러지는 창). `NODA_MAX_PARALLEL=4` 면 **서버 전체에서 동시에 파싱하는
요청**(`/parse`·`/chunk`·`/documents/upload`)이 넷이고 자리가 없으면 **기다리지 않고** `503` 과 `Retry-After: 5` 로 말한다 — 부르는 쪽이
다시 보내는 것이 서버가 줄을 세우는 것보다 낫다. `GET /health` 의 `limits` 가 지금 값과 `parsing_now` 를 낸다.

**API 키** — `NODA_API_KEY` 를 주면 열린 길(`/`·`/demo`·`/docs/*`·`/health`·`/openapi.json` — 읽기뿐) 말고 모든 길이
`Authorization: Bearer <키>`(또는 `X-API-Key`)를 요구한다.
키 없이는 `127.0.0.1` 에만 뜬다 (`--host 0.0.0.0` 은 키가 있어야 선다 · 종료 2). 키는 평문 HTTP 로 가므로 **TLS 는 앞의 프록시가**
한다. `GET /health` 가 `endpoints`·`formats`·`max_upload`·`auth`·`consumers`·`limits` 를 낸다 — 다른 서비스가 먼저 묻는 것들이다.

**소비자가 여럿이면** (W447) `NODA_API_KEYS_FILE` 에 JSON 한 장 `{"챗봇": "키1", "업무자동화": "키2"}` 를 둔다 — 요청마다 다시 읽으므로
키를 더하거나 빼도 서버를 안 내린다 (`NODA_API_KEY` 와 함께 줘도 되고 그 키의 이름은 `default` 다). 파일을 걸 자리가 없는 곳(Coolify 화면 등)은
같은 JSON 을 환경변수 `NODA_API_KEYS` 로 준다 — 이것은 다시 띄워야 읽는다 (W457). 서버는 **요청마다 한 줄** 센다 —
소비자 · 길 · 상태 · 들어온 바이트 · ms. `GET /usage` 가 메모리의 셈을 보여 주고 `NODA_USAGE_LOG` 를 주면 같은 줄을 JSON 으로 파일에도
남긴다 (서버가 내려가도 남는다 · 과금은 그 파일로). **문서 내용은 한 글자도 안 쓴다.** 키가 틀린 요청은 `unknown` 으로 센다.
`examples/noda_client.py` 가 표준 라이브러리뿐인 파이썬 클라이언트 보기다 — 복사해서 쓰면 된다.

`limit` 은 서버가 `MAX_LIMIT`(20)으로 자른다. 부르는 쪽이 더 달라고 해도 막는다.

### `/documents` — 넣어 두고, 무엇이 안 읽혔는지 본다 (TE)

문서는 **테넌트 폴더에 두면 된다.** 서버는 파일을 받지도 지우지도 않는다 —
넣고 빼는 것은 고객의 일이고 색인이 따라간다 (파일을 빼면 다음 부름에서 사라진다).

```bash
curl -s 'localhost:8000/documents?tenant=갑'
```

```json
{"tenant": "갑", "readable": 3, "unreadable": 2, "needs_review": 2, "chunks": 13,
 "documents": [
   {"doc_id": "sha256:…", "filename": "보도자료.hwpx", "readable": true, "chunks": 5,
    "structure": {"title": "2026년 해외농업자원개발 융자사업 시행공고", "doc_number": "국제협력총괄과-174",
                  "issued_date": "2023-01-10", "issuer": "농림축산식품부",
                  "recipient": "수신자 참조", "recipients": ["한국농어촌공사사장(글로벌농업개발부)", "(사)해외농업자원개발협회장"]},
    "review": true, "review_reasons": [
      {"code": "LAYOUT_ANALYSIS_SKIPPED",
       "text": "가로로 나란한 배치가 있다 (표 격자와 문단 나눔이 어긋날 수 있다)"}]},
   {"doc_id": null, "filename": "빈파일.hwpx", "readable": false,
    "reason": "EMPTY", "reason_text": "빈 파일입니다 (0바이트)", "chunks": 0}]}
```

**못 읽은 것을 사유 코드와 함께 말한다** (규칙 6). 그 신고가 여태 HTTP 밖으로
안 나오고 있었다 — 질의를 해 봐야 알았다.

**`structure` 는 문서가 제 서식에 적은 것이다** (W431) — 제목 · 문서번호 · 시행일 · 발신 · 수신 · 수신자 목록.
접수분류(누가 누구에게 언제 보낸 무엇인가)가 문서를 가르는 첫 열쇠인데 여태 이름과 상태만 냈다. 값은 엔진의
`structure`(`docs/schema.md`) **그대로**이고 서식이 없으면 `null`·`[]` 다 — 공고·보도자료에서 수신을 지어내지 않는다.
경유·결재선은 없다 (ADR-0045).

**`reason` 은 계약이고 `reason_text` 는 사람이 읽을 말이다** (W165). 코드로만
내보내면 고객 화면에 `DRM_ENCRYPTED` 가 그대로 찍힌다 — 담당자에게 그것은 아무
말도 아니고 무엇을 하면 되는지도 없다. **고칠 수 있는 것(암호·DRM·깨짐·묶음)은
할 일을 함께 적는다.** 말은 `service/wording.py` 한 곳에 있고 화면도 고객 보고서도
거기서 가져간다. **골라 쓰시라** — 코드로 분기하고 말은 제 것으로 바꿔도 된다.

**무엇이 잠갔는지 이름을 알면 `drm_vendor` 가 함께 온다** (W166) — 한글 배포용
문서가 그렇다. 담당자는 「DRM」보다 그 말을 바로 알아듣는다. **모르면 그 칸을
안 만든다** — 없는 것을 빈 값으로 내보내면 받는 쪽이 있는 줄 안다.

`/search` · `/answer` 의 `skipped` 도 **같은 꼴**이다 (못 읽은 문서의 목록):

```json
{"skipped": [{"filename": "공문.hwp", "readable": false,
              "reason": "DRM_ENCRYPTED",
              "reason_text": "「한글 배포용 문서」라 본문을 못 엽니다 — 보호를 푼 파일로 주십시오",
              "drm_vendor": "한글 배포용 문서"}]}
```

**「읽힘」과 「봐야 함」은 다른 말이다** (W167). `readable` 은 본문을 열었는가이고,
`review` 는 **읽었는데 한 번 눈으로 보라**는 뜻이다 — 표 격자·차례가 어긋날 수 있는
자리다. 섞으면 「못 읽음 32%」로 들린다: 실제로 **못 읽은 것은 0.2%** 이고 나머지는
읽었지만 확인이 필요한 것이다.

`needs_review` 가 **브리프가 파는 숫자**다 — 「100건 중 68건은 원문을 안 열어도
됩니다」(`document_ready_rate`). 못 읽은 것도 여기에 든다.

**어느 경고가 「봐야 함」인지는 엔진이 재서 정했다** — 경고마다 「손든 무리가 조용한
무리보다 실제로 부정확한가」를 라벨로 확인한 자다 (`noda/eval/metrics/review.py`).
**코드와 말을 함께 낸다**: 코드로 거르고 말은 화면에 쓰시라.

**전에는 `"이름 — 사유"` 문자열이었다** — 받는 쪽이 대시로 쪼개야 했고, 파서가
적어 둔 것(보호 이름)이 거기서 사라졌다. 그 필드는 문서에 한 번도 안 적혀 있었다.

**부르면 지금 읽는다.** 첫 질의가 코퍼스 전체 파싱이 되는 것을 막는 길이다 —
실측에서 `/documents` 4.2초 뒤의 첫 `/search` 가 **0.05초**였다.

**zip 은 풀어서 올린다** (UQ). 폴더에 `자료.zip` 을 넣으면 안의 문서가 하나씩 올라가고
`filename` 이 `자료.zip/붙임/계획서.hwpx` 처럼 나온다. 못 꺼낸 항목(암호·깨짐·빈 파일)도
사유를 들고 목록에 나온다. **zip 안의 PDF 는 근거 보기(`/view`)에서 원문 쪽 위에 칠한다** —
서버가 캐시 자리 옆(`<캐시>-unpacked/`)에 한 번 풀어 두고 뷰어에게 그 경로를 준다. 캐시를 끈
서버(`--no-cache`)와 PDF 가 아닌 항목은 글만 보인다.
문서 하나가 늘면 **그 문서만** 다시 읽는다 (TD · 한 건 더하기가 5.1배 빠르다).

**권한대로 걸러진다.** 안 보이는 파일은 **목록에도 없다.**

### `/context` — LLM 에게 넘길 근거 묶음 (W444 · ADR-0047)

소비자가 **제 LLM** 으로 답을 짓는 길이다. 찾은 조각을 글자 예산 안에서 `[n] 파일 · 쪽 · 제목 경로` 머리를 붙여 묶는다 —
프롬프트에 그대로 붙이고, 답의 `[n]` 을 `sources[n-1].anchors` 로 원문 위에 가리킨다. 지시문은 소비자가 붙인다 (`docs/rag.md`).

```bash
curl -s localhost:8000/context -d '{"tenant":"갑","query":"신청기간은?","budget":4000}'
```

```json
{"tenant": "갑", "query": "신청기간은?", "chunks_searched": 13, "budget": 4000, "used": 118, "truncated": false,
 "sources": [{"n": 1, "chunk_id": "c0001", "doc_id": "sha256:6df8…", "filename": "공고.hwpx", "kind": "paragraph",
               "path": ["2026년 지원사업 공고"], "outline": ["3. 신청기간"], "score": 3.41, "found_by": "words",
               "anchors": [{"page": 3, "bbox": [72.0, 640.1, 523.4, 655.9], "cell_range": null}], "extractors": ["hwpx-native"],
               "chars": 17, "cut": false}],
 "context": "[1] 공고.hwpx · 3쪽 · 2026년 지원사업 공고 > 3. 신청기간
신청기간은 8월 28일까지다.", "skipped": []}
```

`budget` 은 **글자**(기본 4,000 · 최대 40,000) — 토큰은 소비자의 토크나이저로. 예산에 안 든 조각이 있으면 `truncated: true` 다.

### `/verify` — 소비자의 답을 근거에 대고 본다 (W449)

`/context` 로 받은 근거로 **제 LLM** 이 답을 지었으면, 그 답에 지어낸 것이 없는지 **서버가 쓰는 규칙 그대로** 물을 수 있다 —
게이트웨이가 제 답에 거는 자(`gateway/grounding.py` · 관문 gate6 와 같은 정규식)를 남의 답에도 건다. `sources` 는 `/context` 의
`sources[]` 를 **그대로 차례대로** (답의 `[n]` 이 `sources[n-1]` 을 가리킨다 · `doc_id` 와 `chunk_id` 가 함께 있어야 한다 — **조각 id 는
문서 안에서만 뜻이 있다**) · `citations` 는 있으면 조각에 그 글자가 있는지도 본다 (`n` 으로 가리키거나 `doc_id`+`chunk_id` 로).

```bash
curl -s localhost:8000/verify -d '{"tenant":"갑","answer":"신청기간은 8월 28일까지입니다 [1]. 예산은 50억 원입니다 [1].","sources":[{"doc_id":"sha256:6df8…","chunk_id":"c0001"}],"citations":[{"n":1,"quote":"8월 28일까지"}]}'
```

```json
{"tenant": "갑", "grounded": false, "evidence_chunks": [{"doc_id": "sha256:6df8…", "chunk_id": "c0001"}], "unknown_sources": [], "unknown_refs": [],
 "sentences": [{"text": "신청기간은 8월 28일까지입니다 [1]", "refs": [1], "numbers": ["28"], "unsupported": []},
               {"text": "예산은 50억 원입니다 [1].", "refs": [1], "numbers": ["50"], "unsupported": ["50"]}],
 "citations": [{"doc_id": "sha256:6df8…", "chunk_id": "c0001", "quote": "8월 28일까지", "found": true, "filename": "공고.hwpx",
                "anchors": [{"page": 3, "bbox": [72.0, 640.1, 523.4, 655.9], "cell_range": null}]}],
 "unsupported_numbers": ["50"]}
```

돌려주는 것은 **판정이 아니라 자리**다 — 어느 문장의 어느 숫자가 근거에 없는지, 어느 인용이 조각에 없는지, 어느 번호가 없는 근거를
가리키는지. `grounded` 는 그 셋이 다 비었을 때만 참이다. **문장을 고쳐 주지 않는다** — 무엇을 할지(그 문장을 버린다 · 사람에게 보인다 ·
다시 묻는다)는 소비자의 몫이다. 규칙의 한계도 그대로다: 숫자는 두 글자부터 센다(`[0-9][0-9,.]+` · 「3억」의 3 은 안 센다) ·
`’26` 이 근거에 있으면 `2026` 을 인정한다 · 글자 대조가 아니라 **숫자와 인용**만 본다 (LLM 은 문장을 새로 쓴다).

### `/documents/upload` · `DELETE /documents` — 테넌트에 넣고 뺀다 (W444)

**키(`NODA_API_KEY`)가 설정된 서버에서만 열린다** — 없으면 403. 넣으면 바로 읽어 `readable`·`reason`·`chunks` 를 함께 돌려준다.
같은 이름은 409 이고 `?replace=1`(또는 `replace: true`)로만 덮는다. `ACL.json` 은 규칙이라 못 넣고 못 지운다. 지워도 파싱 캐시의
글자는 남으므로 `tools/retention.py purge` 로 마저 지운다.

```bash
curl -s -X POST 'localhost:8000/documents/upload?tenant=갑' -H 'Authorization: Bearer 비밀' -H 'X-Filename: %EA%B3%B5%EA%B3%A0.hwpx' --data-binary @공고.hwpx
curl -s -X DELETE 'localhost:8000/documents?tenant=갑&filename=%EA%B3%B5%EA%B3%A0.hwpx' -H 'Authorization: Bearer 비밀'
```

### `/search` — 조각과 인용 근거

```bash
curl -s localhost:8000/search -d '{"tenant":"갑","query":"신청 기간"}'
```

```json
{
  "tenant": "갑",
  "query": "신청 기간",
  "chunks_searched": 13,
  "skipped": [],
  "hits": [{
    "score": 3.41, "found_by": "words",
    "chunk_id": "c0001", "doc_id": "sha256:6df8...",
    "filename": "2026년 지원사업 공고.hwpx",
    "kind": "paragraph",
    "text": "신청기간은 8월 28일까지다.",
    "path": ["2026년 지원사업 공고"],
    "outline": ["3. 신청기간"],
    "anchors": [{"page": 3, "bbox": [72.0, 640.1, 523.4, 655.9], "cell_range": null}],
    "extractors": ["hwpx-native"]
  }]
}
```

**칸마다 무슨 뜻인지는 아래에 이어 적는다.** `text` 는 조각 글자 그대로이고,
`path` 는 문서 제목에서 시작하는 **자리**이고 `outline` 은 그 조각이 놓인 **목차 길**이며(W465 · 색인에는 안 든다), `anchors` 가 **인용 근거**다
(쪽·좌표, 엑셀이면 칸 주소). `kind` 는 그 조각이 문단인지 표인지다.

**`filename` 은 근거의 절반이다** (W184). 같은 문서를 두 포맷으로 받으면
`path`(문서 제목)가 똑같아서 **여덟 줄이 다 같은 한 줄로 보이고**, 그러고도
`anchors` 의 쪽은 **서로 다른 파일**에서 온다 (같은 표가 hwpx 3쪽 · pdf 6쪽).
그러면 고객은 한 문서에 3쪽과 6쪽이 있다고 읽고 **엉뚱한 쪽을 펼친다.**
감사는 「**어느 파일** 몇 쪽」이 없으면 성립하지 않는다.


### `/search` 의 차례와 `score` 를 어떻게 읽나 (SR)

**온 차례가 엔진의 차례다. `score` 로 다시 정렬하지 마라.**

뜻 검색이 켜져 있으면 결과는 **낱말이 찾은 것 절반 + 뜻이 찾은 것 절반**이다
(ADR-0016). 두 점수는 자릿수가 달라 **섞지 않는다** — 자리만 쓴다.

```
score      **낱말(BM25) 점수**다. 뜻만 찾은 조각은 **0.0** 이다 —
           거짓이 아니라 그 조각에 질의 낱말이 하나도 없다는 뜻이고,
           **나쁜 조각이라는 뜻이 아니다**
found_by   "lexical"(낱말이 찾음) · "semantic"(뜻이 찾음)
```

**그 보기를 고쳐 적는다** (ST). SQ 에서 「특별재난지역으로 선포된 곳이 어디인가」에
대해 정답 조각 둘이 `score` 0.0 으로 나왔는데, **그것은 이 설계의 모습이 아니라
벌레의 증상이었다** — 조각 id 가 문서마다 다시 세는 것을 합치는 쪽이 안 가려서
낱말이 찾은 46.75점짜리 정답을 2.23점짜리 딴 문서 조각으로 바꿔 놓고 있었다.
고친 뒤에는 그 정답이 **`found_by=lexical` · `score` 46.75 로 1위**다.

위의 설명(차례를 다시 정렬하지 마라 · 뜻이 찾은 것은 0.0 이다)은 **그대로 맞다** —
다만 「0.0 인데 정답이더라」를 놀라운 일로 적어 두면 안 된다. 그런 일은 **낱말이
정말로 안 닿을 때** 생기고, 위 사례는 그 경우가 아니었다.

```bash
curl -s localhost:8000/answer -d '{"tenant":"갑","question":"신청기간은?"}'
```

```json
{
  "tenant": "갑",
  "question": "신청기간은?",
  "answered": true,
  "reason": null,
  "skipped": [],
  "made_by": "발췌 (LLM 없음)",
  "text": "신청기간은 8월 28일까지다.",
  "citations": [{
    "chunk_id": "c0001", "doc_id": "sha256:6df8...",
    "filename": "2026년 지원사업 공고.hwpx",
    "quote": "신청기간은 8월 28일까지다.",
    "path": ["2026년 지원사업 공고"],
    "outline": ["3. 신청기간"],
    "anchors": [{"page": 3, "bbox": [72.0, 640.1, 523.4, 655.9], "cell_range": null}],
    "extractors": ["hwpx-native"],
    "revisions": []
  }]
}
```

**`answered` 가 거짓이면 `reason` 이 까닭을 든다** — `NO_MATCH`(검색이 아무것도 못
찾았다) · `NO_EVIDENCE`(찾았지만 답이 될 줄이 없었다). **조용히 빈 답을 주지 않는다**
(규칙 6). `skipped` 는 그 테넌트에서 **못 읽은 문서**의 목록이고 위와 같은 꼴이다 —
답이 짧을 때 「문서가 안 들어온 것인가」를 먼저 보시라.


## 화면 — 근거를 원문 위에 칠한다

    http://localhost:8000/view?tenant=갑&question=지원대상은 누구인가

브라우저 주소창에서 바로 열린다. 답 아래에 **원문 쪽 그림**과 인용한 글자
자리에 칠한 자국이 함께 온다.

- **PDF 와 그림 파일(PNG·JPG·TIF 등)이 그림을 굽는다** (W164 — 그림은 그 자체가
  쪽이라 박아 넣는다). HWPX·HWP·DOCX 는 원문 그림이 없어 쪽·좌표를 글자로만 낸다 —
  없는 그림을 지어내지 않고 **왜 못 칠하는지 화면에 적는다** (W163) — 좌표가
  없는 것과 쪽 그림이 없는 것을 갈라 말한다 (ADR-0004)
- **못 읽은 문서가 있으면 화면 머리에 적는다** (W185) — 이름 · 까닭 · 할 일과 함께
  「**답과 근거에는 그 문서가 들어 있지 않습니다**」를 적는다. 이 HTML 은 **혼자
  돌아다니므로**(메일 · 결재 · 보관) JSON 의 `skipped` 만으로는 모자라다
> **`llm=true` 는 문서 글을 밖으로 보낸다** (W168). 엔진은 네트워크를 안 쓰지만
> (규칙 4) 게이트웨이는 조각 글을 **제공자에게 그대로** 보낸다. 어디로 가는지는
> `GET /health` 가 말한다:
>
> ```json
> {"ok": true, "llm": {"configured": true, "host": "api.openai.com",
>                      "model": "gpt-4.1-mini", "external": true}}
> ```
>
> `external` 이 거짓이면 **아무것도 안 나간다** — `NODA_LLM_BASE_URL` 로 사내 모델을
> 가리킨 것이다 (vLLM·Ollama·LM Studio 가 같은 모양의 API 를 낸다). **키는 안 낸다** —
> 있는지만 말한다. 폐쇄망에서 쓰실 곳은 이 칸을 먼저 보시라.

- `llm=true` 를 주면 게이트웨이를 거친다. **키가 없으면 400 이다** —
  조용히 발췌로 내려가지 않는다 (규칙 6)

`POST /answer` 도 같다 — `{"llm": true}` 를 주면 게이트웨이를 거치고
`made_by` 가 **`"LLM 게이트웨이 · 인용 검증됨"`** 으로 바뀐다. **키가 없으면 400 이다** —
조용히 발췌로 내려가면 부르는 쪽은 LLM 이 답한 줄 안다 (RS).
- 화면 머리말에 **무엇이 답을 지었는지** 적힌다 (`발췌 (LLM 없음)` / `LLM 게이트웨이`)

인용에 `revisions` 가 붙어 오면 **그 줄이 무엇을 바꾸는지**다 (BQ·BR·BY).

```json
"revisions": [{
  "kind": "deleted", "label": "13.",
  "current": "13. 법 제104조제1항에 따른 위법사실 통보의 수리",
  "revised": "<삭 제>"
}]
```

`added`(신설) · `deleted`(삭제) · `amended`(고침) · `unchanged`(그대로) ·
`renumbered`(번호이동). 신·구조문대비표의 관례(`<신 설>` · `-----`)를 **고객 UI 가
읽어 낼 필요가 없게** 뜻을 곁에 붙인 것이고, **인용 글자는 원문 그대로다.**

`/view` 화면에도 `[삭제 13.]` 처럼 함께 찍힌다.

**`renumbered` 를 「그대로」 와 같이 다루면 안 된다** — 글은 같지만 조 번호가
바뀐다. 낡은 번호로 인용하면 개정 뒤 문서에서 그 조를 못 찾는다.

**`extractors` 를 반드시 보여 줘라.** `ocr` 이 들어 있으면 그 글자는 기계가
그림에서 읽은 것이고 원문이 그렇게 적혀 있다는 보장이 없다 (ADR-0010).
`bbox` 가 `null` 이면 그 포맷에 좌표가 없다는 뜻이다 — 지어내지 않는다 (ADR-0004).
`cell_range` 는 엑셀의 시트와 셀 범위(`훈시사항!A3:D20`)다 — 좌표가 없는 대신 **원문이 쓰는 주소**를 준다.
엑셀이 아니면 `null` 이다 (ADR-0022). xlsx 조각은 **그 조각이 담은 행으로 좁힌 주소**를 든다 (MK·ML) —
큰 시트를 여러 조각으로 자르면 조각마다 제 행만 가리킨다. `.xls` 는 아직 못 읽어 조각이 없다.

### `/parse` · `/chunk` — 파일을 넣으면 JSON 이 나온다 (W441 · ADR-0046)

**다른 서비스가 이 엔진을 쓰는 첫 문이다.** 테넌트도 폴더도 없다 — 파일의 바이트를 보내면
`docs/schema.md` 계약의 JSON(`python -m noda.parse` 와 같은 것)이 온다. 이름은 `X-Filename`
(퍼센트 인코딩 · 한글 그대로 된다) 또는 `?filename=` 으로 준다 — 포맷 판별(확장자 불일치 경고)과
인용(`source.filename`)에 쓰인다. 경로는 떼고 이름만 쓴다.

```bash
curl -s --data-binary @공고.hwpx -H 'X-Filename: %EA%B3%B5%EA%B3%A0.hwpx' localhost:8000/parse
```

```json
{"doc_id": "sha256:6df8…", "schema_version": "0.1",
 "source": {"filename": "공고.hwpx", "format": "hwpx", "bytes": 48213, "pages": 3, "sha256": "6df8…", "container_path": []},
 "access": {"readable": true, "drm": false, "drm_vendor": null, "password_protected": false, "reason": null},
 "blocks": [{"id": "b00001", "type": "paragraph", "text": "2026년 지원사업 공고", "page": 1, "bbox": null,
             "parent": null, "level": null, "extractor": "hwpx-native", "confidence": 1.0}],
 "tables": [], "structure": {"title": "2026년 지원사업 공고", "doc_number": null, "issued_date": null, "issuer": null,
                             "recipient": null, "recipients": [], "sections": [], "attachments": [], "revisions": []},
 "warnings": [], "metrics": {"parse_ms": 212, "extractor_mix": {"hwpx-native": 1.0}}, "acl": null}
```

`/chunk` 는 같은 바이트로 **문서와 조각**을 함께 준다 — `{"document": …, "chunks": [{"id", "doc_id", "kind", "text",
"blocks", "anchors", …}]}`. `?budget=600` 으로 조각의 글자 수를 준다.

**JSON 으로 보내도 된다** — `{"filename": "공고.hwpx", "data_base64": "…"}` 를 `Content-Type: application/json` 으로.
고객 스택이 `handle` 을 감싼 자리에서는 이 꼴이 그대로 `handle(registry, "parse", body)` 다.

**못 읽어도 200 이다.** 못 읽은 것은 요청의 잘못이 아니라 문서의 사실이고 계약이 `access.readable=false · reason` 으로
말한다 (규칙 6). **4xx 는 요청이 틀렸을 때만이다** — 이름이 없다(400) · base64 가 깨졌다(400) · 파일이 상한을 넘는다(413 ·
기본 100MB · `NODA_MAX_UPLOAD` 바이트).

## 테넌트

**색인이 곧 경계다.** 테넌트마다 제 색인이 서고 남의 조각은 거기 **없다** —
점수가 낮은 것이 아니라 없다. 공유 색인에서 상위 k 를 뽑고 권한으로 거르는
구조는 만들지 않았다 (ADR-0019·0020).

테넌트 이름은 디렉터리 이름 하나여야 한다. `..` 도 구분자도 400 으로 거절한다.

## 무엇을 갈아끼울 수 있나 — **이음매는 조각이다**

**조각(`/search` 의 `hits`, `/answer` 의 `citations` 가 가리키는 것)이 제품입니다.**
색인은 문서를 들고 있지 않아 **조각만 있으면 섭니다** — 그래서 검색을 고객의 것으로
(Elasticsearch·벡터DB 등) 갈아끼우실 수 있습니다. 조각을 넣고 조각을 받으면 됩니다.

**다만 발췌 답변(기준선)은 참조 색인 위에 섭니다.** `noda.answer` 가 참조 `search()` 를
직접 부르므로 BM25 내부(`postings`·`document_frequency`·`average_length`)까지
요구합니다 — 일부러 그렇습니다: 점수의 낱말 무게가 **색인이 이미 센 값과 같아야**
어긋나지 않습니다. 그래서 **검색을 갈아끼우시면 그 위의 답변 층도 고객의 것**이거나
**게이트웨이(LLM)** 입니다 (ADR-0018 이 발췌를 **기준선**이라 부르는 까닭입니다).

| 갈아끼울 것 | 무엇을 주면 되나 |
|---|---|
| 색인·검색 | 조각을 받아 조각을 돌려주면 됩니다 |
| 발췌 답변(기준선) | 참조 색인을 그대로 쓰거나, 답변 층도 고객의 것으로 |
| 파싱·조각내기 | 갈아끼우실 것이 아닙니다 — 그것이 이 엔진입니다 |

## 참조 서버의 한계 — 그리고 바꾸는 법

처음에는 **한 번에 한 요청 · 인증 없음**의 시연용이었다 (ADR-0020). W441 에서 **서비스로 세웠다** (ADR-0046) — `ThreadingHTTPServer`
로 요청이 서로를 안 기다리고(테넌트 색인을 만지는 길만 잠금 하나로 차례), `NODA_API_KEY` 로 키를 걸고, `/parse`·`/chunk` 로 파일을 받는다.
여전히 표준 라이브러리뿐이다. **없는 것**: TLS(앞의 프록시) · 감사 로그(감싸는 층 — 사용량 줄 `NODA_USAGE_LOG` 는 있다) · 여러 프로세스(파싱은 CPU 를 쓰므로
스레드로는 동시에 빨라지지 않는다 — 처리량이 필요하면 프로세스를 여럿 띄운다 · 요청 제한은 프로세스 안에서만 세므로 그때는 프록시가 센다).

`service/routes.py` 는 **HTTP 를 모른다.** dict 를 받아 dict 를 돌려준다.
테넌트마다 제 색인을 세우는 등록부는 `service/registry.py` 에 있다 (HM) —
`routes` 가 거기서 `Registry` 를 가져간다.
그래서 고객의 스택으로 감싸는 데 다섯 줄이면 된다.

```python
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from service import Registry, handle

app, registry = FastAPI(), Registry.of("/srv/corpus")

@app.post("/{action}")
async def call(action: str, body: dict):
    status, payload = handle(registry, action, body)
    return JSONResponse(payload, status_code=status)
```

인증·감사 로그·요청 제한은 **그 층에서** 붙인다. 엔진이 할 일이 아니다 (규칙 9).

## 캐시

**서버는 파싱 캐시를 기본으로 쓴다** (ADR-0021). 뜰 때 자리를 찍는다.

    파싱 캐시: C:\Users\a\AppData\Local\noda\cache (옛 세대 1개 지움)

    --cache <자리>   다른 자리에 쓴다
    --no-cache       쓰지 않는다 (첫 질의가 코퍼스 전체 파싱이 된다)

실측 — 문서 43건 테넌트의 첫 질의가 **4.19초 → 0.53초**. 디스크는
코퍼스의 약 10% 이고, 파서를 고쳐 생긴 옛 세대는 **둘만 남기고 지운다.**

### 캐시에 무엇이 들어 있나 — **문서 글자다**

빠르게 하는 장치로만 읽으면 안 된다. **캐시 파일 하나가 `Document` 전체 JSON** 이고,
거기에는 **블록 글자와 표 칸이 통째로** 들어 있다. 감싸실 때 세 가지를 아셔야 한다:

```
원본과 다른 자리에 사본이 생긴다   기본이 %LOCALAPPDATA%
oda\cache 다.
                                **원본 폴더에 건 권한이 캐시 자리에는 안 따라간다**
테넌트를 안 가른다                캐시 자리는 **서버 전체에 하나**다
                                (`Registry.cache_root`). 색인은 권한 무리마다
                                가르는데(아래 ACL 절) **디스크는 안 가른다**
원본을 지워도 남는다              캐시는 따로 지워야 한다
```

**같은 기계에서 같은 권한으로 보면 새 노출은 아니다** — 원본도 거기 있다.
값나가는 사실은 **사본이 다른 자리에 생긴다**는 것이다.

무엇을 하실 수 있나:

```
--cache <자리>       원본과 같은 권한이 걸린 자리로 옮긴다
--no-cache           아예 안 쓴다 (첫 질의가 코퍼스 전체 파싱이 된다)
tools/retention.py   원본을 지울 때 **캐시 자리까지 함께** 지우고 확인증을 낸다
```

**캐시를 테넌트별로 가르지는 않는다.** 열쇠가 내용 해시라 같은 문서를 두 테넌트가
나눠 쓰는 것이 지금의 값이고, 바꾸려면 그 맞바꿈을 먼저 재야 한다.

색인은 테넌트별로 메모리에 서고 **파일이 바뀌면 다시 세운다** —
서버가 오래 살아도 낡은 답을 주지 않는다.

## 접근 제어 — 권한 무리는 색인이다

문서 단위 접근 제어는 테넌트 폴더의 `ACL.json` 사이드카로 정한다.
엔진 스키마는 이것을 모른다 — 제품별 요구는 엔진 바깥이다 (규칙 9).

**이것은 색인의 격리다. 디스크의 격리가 아니다** — 파싱 캐시는 서버 전체에
자리 하나이고 거기에 모든 테넌트의 문서 글자가 담긴다 (위 「캐시」 절).
**질의로는 안 샌다** (색인에 없는 문서는 없는 것이다). **파일 권한은 따로 거셔야 한다.**

```json
{
  "default": ["*"],
  "rules": [
    {"files": "인사_*.hwpx", "allow": ["hr", "대표"]}
  ]
}
```

- 요청에 `principal` 을 넣으면 그 주체가 볼 수 있는 문서만으로 **색인을
  세운다.** 공유 색인에서 뒤늦게 거르지 않는다 — 안 보이는 문서는 점수가
  낮은 것이 아니라 없고, 결과 개수로도 존재를 흘리지 않는다
- `principal` 이 없으면 `default` 에 `"*"` 가 있는 문서만 보인다.
  `ACL.json` 이 없으면 전부 공개다 — 없던 시절과 같다
- 규칙은 위에서부터 처음 맞는 것이 이기고, **깨진 ACL 은 전부 잠금**이다 —
  규칙을 못 읽었는데 공개로 돌면 오타 하나가 유출이다
- 같은 허용 집합을 가진 주체들은 색인을 나눠 쓴다 — 주체가 백 명이어도
  권한 무리가 셋이면 색인은 셋이다
- **인증은 여기 없다.** 주체가 누구인지는 고객 스택(게이트웨이·SSO)이
  정해서 `principal` 로 넘긴다. 이 참조 구현이 지키는 것은 「주어진 주체가
  무엇을 볼 수 있는가」의 계약뿐이다

```bash
curl -X POST localhost:8756/search \
  -d '{"tenant": "갑", "principal": "hr", "query": "연봉 기준"}'
```
