# คู่มือสำหรับ Agent — Thinker Docs

คุณคือ agent ที่กำลังเชื่อมต่อกับ **Thinker Docs**: บริการ on-prem ที่แปลง **เทมเพลตที่เผยแพร่แล้ว** + **ข้อมูล JSON** → **ไฟล์ PDF**

เอกสารนี้เขียนให้ agent อ่าน ไม่ใช่ให้คนอ่าน มันบอก **การตัดสินใจที่คุณต้องทำ**, **โครงสร้างข้อมูลที่แน่นอน** และที่สำคัญที่สุดคือ **ความผิดพลาดที่ดูเหมือนถูก** ทุกอย่างในนี้เขียนจากโค้ดที่รันอยู่จริง — ถ้าเอกสารนี้ขัดกับโค้ด **โค้ดถูก และเอกสารนี้คือบั๊ก**

---

## ลำดับการทำงาน (Sequence Diagram)

### sync — ได้รับไฟล์ PDF กลับมาทันที

```mermaid
sequenceDiagram
  autonumber
  participant C as ระบบที่เรียก
  participant A as Thinker Docs API
  participant D as PostgreSQL
  participant R as pdfme

  C->>A: GET /v1/templates/:id/fields?version=2
  A-->>C: 200 สัญญาฟิลด์ (ส่งอะไรบ้าง)
  C->>A: POST /v1/generate (mode "sync", data)
  A->>A: ตรวจ data กับสัญญา — ผิด → 400 invalid_data
  A->>D: อ่านเทมเพลตเวอร์ชันที่ระบุ
  A->>R: เรนเดอร์
  A->>D: เขียน audit_log (ไม่เก็บ data)
  A-->>C: 200 application/pdf ← ไฟล์ · ไม่เก็บไว้เลย
```

### async — ได้ลิงก์ (ไฟล์ถูกเก็บไว้ให้)

```mermaid
sequenceDiagram
  autonumber
  participant C as ระบบที่เรียก
  participant A as Thinker Docs API
  participant S as Object storage
  participant D as PostgreSQL

  C->>A: POST /v1/generate (mode "async", data)
  A->>A: เรนเดอร์ + คำนวณ sha256
  A->>S: เก็บไฟล์ PDF
  A->>D: บันทึกแถว documents (มีวันหมดอายุ) + audit_log
  A-->>C: 201 { documentId, url, sha256, expiresAt }
  Note over C,A: url มีอายุจำกัด — อย่าเก็บ<br/>ให้เก็บ documentId
  C->>A: GET /v1/documents/:id  (เมื่อไหร่ก็ได้)
  A-->>C: 200 พร้อม url ที่ออกใหม่
  Note over A,S: เลย expiresAt แล้ว → 410 Gone<br/>ไฟล์ถูกลบ แต่ audit row ยังอยู่
```

### attach — รวมเอกสาร แล้วเลขหน้าถูกคำนวณใหม่

```mermaid
sequenceDiagram
  autonumber
  participant C as ระบบที่เรียก
  participant A as Thinker Docs API

  C->>A: POST /v1/generate (mode "async") — สำเนาบัตร 2 หน้า
  A-->>C: 201 { documentId: "ID-SCAN" }
  C->>A: POST /v1/generate (สัญญา 2 หน้า, options.attach ["ID-SCAN"])
  A->>A: เรนเดอร์สัญญา (พักหัว/ท้ายกระดาษไว้ก่อน)
  A->>A: ตรวจว่า ID-SCAN เป็นของทีมนี้ — ถ้าไม่ใช่ → 404
  A->>A: รวมหน้า → 4 หน้า
  A->>A: วาดลายน้ำ + เลขหน้าทับ "เอกสารสุดท้าย"
  A-->>C: เอกสาร 4 หน้า · ท้ายหน้า 3 เขียนว่า "หน้า 3 จาก 4"
```

---

## 0. โมเดลของระบบใน 1 ย่อหน้า

คนอัปโหลด PDF เดิมของธนาคาร ลากฟิลด์ไปวางบนนั้น แล้ว **เผยแพร่** การเผยแพร่จะแช่แข็ง **เวอร์ชัน** หนึ่งไว้ พร้อม **สัญญาฟิลด์ (field contract)** ที่คงที่ตลอดไป คุณส่ง `data` ที่ตรงกับสัญญานั้น ระบบพิมพ์ลงบน PDF แล้วคืนเอกสารให้ **คุณไม่เคยออกแบบเทมเพลต และไม่เคยส่งฟิลด์ที่สัญญาไม่ได้ระบุไว้**

---

## 1. ตัดสินใจ 3 อย่างก่อนเรียกครั้งแรก

### 1.1 `mode` — ตัวนี้ตัวเดียวที่ตัดสินว่าคุณจะได้อะไรกลับ

| `mode` | ได้อะไรกลับ | เก็บไฟล์ไหม | ใช้เมื่อ |
| --- | --- | --- | --- |
| `"sync"` | **ไฟล์ PDF** (`Content-Type: application/pdf`) | **ไม่เก็บ** (แต่ยังลง audit log) | ส่งไฟล์ต่อให้ผู้เรียกทันที ไม่ต้องเก็บ |
| `"async"` | **JSON** `{ documentId, url, sha256, expiresAt }` | เก็บ | ต้องแนบเอกสารกับเคส หรือต้องใช้อีกภายหลัง |

> **ความผิดพลาดที่ agent ทำบ่อยที่สุด: คาดว่าจะได้ทั้งสองอย่าง** คุณได้ **ไฟล์ หรือ ลิงก์** อย่างใดอย่างหนึ่ง **ไม่มีวันได้ทั้งคู่** และไม่มี `options` ตัวไหนเปลี่ยนเรื่องนี้ได้ — ไม่ใช่ `attach` ไม่ใช่ `watermark` ไม่ใช่ `protection` มีแต่ `mode` เท่านั้น

`url` **มีอายุจำกัด** อย่าเก็บมันไว้ ให้เก็บ `documentId` แล้วขอลิงก์ใหม่จาก `GET /v1/documents/:id` เมื่อไหร่ก็ได้

### 1.2 ระบุ `version` เสมอ

```jsonc
{ "templateId": "…", "version": 2 }   // ← ส่งเสมอ
```

ถ้าไม่ส่ง `version` ระบบจะใช้เวอร์ชันที่เผยแพร่อยู่ **ณ ขณะนั้น** — พรุ่งนี้มีคนเผยแพร่ v3 ระบบของคุณจะเริ่มผลิตเอกสารคนละแบบโดยไม่มีใครรู้ **ให้ระบุเวอร์ชันที่คุณทดสอบไว้** `templateId` ไม่เคยเปลี่ยน มีแต่ `version` ที่ขยับ

### 1.3 ถามสัญญาฟิลด์ อย่าเดา

```
GET /v1/templates/{templateId}/fields?version={n}
X-Api-Key: $THINKER_DOCS_API_KEY          # scope: templates:read
```

```jsonc
200 OK
{
  "templateId": "50e3a8aa-…",
  "version": 2,
  "status": "published",
  "fields": [
    { "name": "case_id", "type": "text", "required": true },
    { "name": "loanAmount", "type": "number", "format": { "decimals": 2, "prefix": "฿" } },
    { "name": "signedOn", "type": "date", "format": { "calendar": "buddhist", "pattern": "d MMMM yyyy" } }
  ]
}
```

ส่ง `name` ให้ตรงตามนี้ทุกประการ **ฟิลด์ที่คุณคิดขึ้นเองจะถูกปฏิเสธ ฟิลด์บังคับที่คุณลืมส่งก็ถูกปฏิเสธ** สัญญานี้ถูกสร้างจากตัวเทมเพลตเอง จึงไม่มีทางไม่ตรงกับสิ่งที่ `generate` บังคับใช้

ส่วนใหญ่ `type` คือ `text`/`number`/`date` แต่ Field Discovery อาจคืน `type` อื่นที่ค่าใน `data` ต้องส่งให้ถูกรูป มิฉะนั้นได้ `invalid_data`:

*   `image` — **Data URL เท่านั้น** (`"data:image/png;base64,…"`) ไม่รับ URL แบบ http(s) และภาพต้องไม่เกิน 30 ล้านพิกเซล
*   `qrcode` — ข้อความที่จะเข้ารหัสเป็น QR (เช่นลิงก์ยืนยันเอกสาร)
*   `table` — **อาเรย์ 2 มิติของสตริง** เช่น `[["Alice","NY"],["Bob","LA"]]` หรือสตริง JSON ของอาเรย์นั้น (แต่ละแถวคือหนึ่งบรรทัด) สูงสุด 2,000 แถว / 20,000 เซลล์
*   `svg` — **มาร์กอัป SVG อินไลน์** (`"<svg …>…</svg>"` — **ไม่ใช่** data URL) ระบบปฏิเสธ `<script>`, ตัวจัดการ `on*=`, `<!DOCTYPE>`/`<!ENTITY>` และการอ้างอิงภายนอก (`href`/`xlink:href`/`src`) — ถ้ามีจะได้ `invalid_data`

---

## 2. สร้างเอกสาร

```
POST /v1/generate
X-Api-Key: $THINKER_DOCS_API_KEY          # scope: generate
Content-Type: application/json
```

```jsonc
{
  "templateId": "50e3a8aa-…",
  "version": 2,
  "mode": "async",
  "data": {
    "case_id": "LN-2569-001",
    "loanAmount": 1250000,
    "signedOn": "2026-05-19"
  },
  "options": { /* ไม่บังคับ — ดู §3 */ }
}
```

**ส่งค่าเป็นชนิดข้อมูล JSON ตามธรรมชาติ อย่าจัดรูปแบบมาให้** ส่งตัวเลข `1250000` ไม่ใช่ `"฿ 1,250,000.00"` — เทมเพลตเป็นเจ้าของการจัดรูปแบบ การแปลง พ.ศ. และเลขไทย **ถ้าคุณจัดรูปแบบมาเอง เอกสารจะถูกจัดรูปแบบสองรอบ**

วันที่ใช้ **ISO**: `"2026-05-19"` เทมเพลตเป็นคนตัดสินว่าจะพิมพ์เป็น `19/05/2026` หรือ `19 พฤษภาคม 2569` **อย่าบวก 543 เอง**

---

## 3. `options` — และตัวที่จะทำให้คุณพลาด

### `metadata` — ตั้งชื่อเอกสาร

```jsonc
"options": { "metadata": { "title": "สัญญาเงินกู้ LN-2569-001", "subject": "case=LN-2569-001" } }
```

> ⚠️ **metadata ของ PDF ไม่เคยถูกเข้ารหัส และติดไปกับไฟล์ตลอดกาล** ใครเปิดดู properties ก็อ่านได้ ใส่ได้แค่ **ตัวอ้างอิง** — **ห้ามใส่ชื่อลูกค้า เลขบัตรประชาชน หรือจำนวนเงิน** ไม่มีระบบไหนบังคับเรื่องนี้ได้ มีแต่คุณที่รู้ว่าค่าที่ใส่หมายถึงอะไร

### `protection` — ใส่รหัสผ่านให้ไฟล์ของผู้รับ

```jsonc
"options": {
  "protection": {
    "userPassword": "…",                                   // ใช้เปิดไฟล์
    "ownerPassword": "…",                                  // ถ้าไม่ระบุ ใช้ userPassword
    "permissions": { "print": true, "copy": false, "modify": false }
  }
}
```

AES-256 ผ่าน `qpdf` ต้องมีรหัสผ่านอย่างน้อยหนึ่งตัว (มิฉะนั้นจะได้รับ **400**) หากเซิร์ฟเวอร์ไม่มี `qpdf` จะตอบ **503 `protection_unavailable`** — ระบบ **จะไม่ส่งไฟล์ที่ไม่ได้เข้ารหัสกลับมาแทน**

> **รหัสผ่านไม่ถูก log ไม่ถูกเก็บ ไม่ถูกใส่ใน metadata และไม่ถูกส่งกลับ** audit log บันทึกแค่ *ว่า* เอกสารถูกป้องกัน — ไม่เคยบันทึกว่าด้วยอะไร **อย่าคาดหวังว่าจะอ่านมันกลับมาได้ และอย่านำไปใส่ใน `metadata`**
>
> ⚠️ รหัสผ่าน PDF **ไม่ใช่การเข้ารหัสข้อมูลที่จัดเก็บ (encryption at rest)** มันปกป้องแค่ไฟล์ของผู้รับเท่านั้น

### `attach` — เอาหน้าของเอกสารอื่นมาต่อท้าย

```jsonc
"options": { "attach": ["8108147a-…", "c3f9b1e2-…"] }     // สูงสุด 10
```

id เหล่านี้คือ **`documentId` จากการเรียก `async` ก่อนหน้า** — ไม่มีที่มาอื่นเลย ดังนั้น `attach` แปลว่าคุณต้องเคยเรียก `async` มาแล้ว และ id จะถูกตรวจกับ **ทีมของคุณเอง** — id ของทีมอื่นจะได้ **404** เหมือนกับ id ที่ไม่เคยมีอยู่จริง

**เลขหน้าจะถูกคำนวณใหม่ตามเอกสารที่รวมแล้ว** ท้ายกระดาษที่เขียนว่า `หน้า {currentPage} จาก {totalPages}` จะนับหน้าที่มีอยู่จริงหลัง merge — สัญญา 2 หน้า + สำเนาบัตร 2 หน้า จะขึ้นว่า "หน้า 3 จาก 4" ที่หน้า 3 **คุณไม่ต้องทำอะไรเพิ่ม และอย่าพยายามชดเชยเอง**

### `watermark` — ประทับสถานะ

```jsonc
"options": { "watermark": { "text": "ฉบับร่าง", "opacity": 0.15 } }   // ข้อความไม่เกิน 60 ตัวอักษร
```

ประทับ **ทุกหน้า รวมถึงหน้าที่แนบมาด้วย**

> หมายเหตุ: เทมเพลตอาจมี **ฟิลด์ลายน้ำ** ของตัวเองอยู่แล้ว ซึ่งคนออกแบบเป็นคนวาง ส่วน option นี้คือลายน้ำที่ **คุณ** ตัดสินใจเป็นราย request — เช่นเพราะเคสอยู่ในสถานะไหนใน workflow ซึ่งคนออกแบบเทมเพลตไม่มีทางรู้ แต่คุณรู้

### `bulkOutput` — ตอบ **501**

ยังไม่ได้สร้าง มันจะ **ถูกปฏิเสธ ไม่ใช่ถูกเมิน** ผู้เรียกที่ขอ bulk output แล้วได้ PDF ใบเดียวกลับไปคือการถูกโกหก ระบบจึงเลือกที่จะล้มเหลวแทน

---

## 4. ดึงเอกสารแบบ async

```
GET /v1/documents/{documentId}
X-Api-Key: $THINKER_DOCS_API_KEY          # scope: documents:read
```

- ปกติ → JSON พร้อม `url` ที่ **ออกใหม่** (ลิงก์จาก generate หมดอายุไปแล้ว)
- `?download=1` → ส่งไฟล์ PDF กลับมาโดยตรง

เอกสารของทีมอื่น → **404** · เลย `expiresAt` แล้ว → **410 Gone** (ไฟล์มี PII จึงถูกลบตามกำหนด แต่ audit row ยังอยู่เพื่อพิสูจน์ว่าเคยมี)

---

## 5. ข้อผิดพลาด — ให้แตกเงื่อนไขที่ `code` ไม่ใช่ที่ข้อความ

| HTTP | `code` | คุณควรทำอะไร |
| --- | --- | --- |
| 400 | `invalid_data` | `data` ผิดสัญญา คำตอบมี `fields: [{field, reason}]` มาให้ **แก้แล้วส่งใหม่ — ห้ามส่งซ้ำแบบเดิม** |
| 400 | `bad_request` | body ผิดรูป, `mode` ผิด, `protection` ไม่มีรหัสผ่าน, `watermark.text` ยาวเกิน, เรียก `async` บนฉบับร่าง |
| 401 | `unauthenticated` | ไม่มีหรือ `X-Api-Key` ผิด |
| 403 | `insufficient_scope` | คีย์ไม่มี scope ที่การเรียกนี้ต้องใช้ |
| 404 | `not_found` | ไม่พบ **หรือไม่ใช่ของทีมคุณ** — แยกไม่ออกโดยเจตนา |
| 410 | `gone` | เอกสารหมดอายุและถูกลบแล้ว |
| 413 | `payload_too_large` | body เกิน 1 MB |
| 422 | `render_failed` | **เทมเพลต** เรนเดอร์ไม่ผ่าน (รวมถึงกรณีเรนเดอร์นานเกินกำหนดแล้วถูกตัดจบ) ไม่ใช่ความผิดของข้อมูลคุณ **อย่าลองใหม่** ให้แจ้งเจ้าของเทมเพลต |
| 429 | `rate_limited` | เคารพ `Retry-After` **หน่วงเวลาก่อนเรียกซ้ำ อย่าเรียกถี่ต่อเนื่อง** (เพดานค่าเริ่มต้น 120 คำขอ/นาที ต่อคีย์) |
| 501 | `not_implemented` | คุณขอ option ที่ยังไม่มี |
| 503 | `protection_unavailable` | เซิร์ฟเวอร์ไม่มี `qpdf` — ภาวะ**ถาวร** **ห้าม fallback ไปใช้ไฟล์ที่ไม่ได้ล็อก และห้ามลองใหม่** |
| 503 | `overloaded` | เซิร์ฟเวอร์กำลังเรนเดอร์เต็มกำลัง — ภาวะ**ชั่วคราว** **เคารพ `Retry-After` แล้วลองใหม่แบบ exponential backoff** (แยกจาก `protection_unavailable` ให้ดูที่ `code` ไม่ใช่แค่สถานะ 503) |

**ตัวอย่าง `invalid_data`** — ความล้มเหลวที่พบบ่อยที่สุด ถูกจับที่ชายแดนก่อนที่จะสร้าง PDF แม้แต่ไบต์เดียว:

```jsonc
400 Bad Request
{
  "error": "data does not match the template field contract",
  "code": "invalid_data",
  "fields": [
    { "field": "loanAmount", "reason": "expected a number" },
    { "field": "case_id",    "reason": "required" }
  ]
}
```

---

## 6. กฎที่ห้ามละเมิด

1. **404 แปลว่า "ไม่ใช่ของคุณ หรือ ไม่มีอยู่" และคุณแยกไม่ออก** นี่คือโมเดลการแยกข้อมูล ไม่ใช่ช่องโหว่ของ API **ห้ามสุ่มส่ง id เพื่อหาความแตกต่าง** ทุกครั้งที่ถูกปฏิเสธจะถูกบันทึกใน audit log ด้วย `outcome: denied`
2. **ห้าม log `data`** มันมี PII — ชื่อ เลขบัตรประชาชน จำนวนเงิน ระบบไม่ log มัน และคุณก็ห้าม
3. **ห้ามส่งรหัสผ่านไปที่อื่นนอกจาก `options.protection`** ไม่ใส่ใน `metadata` ไม่ใส่ใน log ไม่ใส่ในคิว retry ที่คุณเก็บลงดิสก์
4. **ห้ามลองใหม่กับ `422 render_failed` และ `400`** ทั้งคู่เป็น deterministic ลองใหม่ก็ได้ผลเดิม และทำให้ audit log สกปรก
5. **API key คืออัตลักษณ์ของเครื่อง ที่ถือสิทธิ์ของ "ทีม"** ไม่ใช่ของคน มันไม่หยุดทำงานเมื่อพนักงานลาออก การหยุด integration คือ **revoke คีย์** ไม่ใช่จัดการที่ตัวคน
6. **คีย์เรนเดอร์ฉบับร่างไม่ได้** ฉบับร่างยังไม่ผ่าน lint จึงเป็นเทมเพลตที่ระบบ**เชื่ออยู่แล้วว่าจะผลิตเอกสารผิด** เรียก `async` บนฉบับร่าง → **400**

---

## 7. `sha256` — วิธีพิสูจน์ว่าเอกสารไหนคือของคุณ

ทุกคำตอบมี `sha256` ให้ hash ไฟล์ที่คุณถืออยู่แล้วเทียบ มันคือหลักฐานการกำเนิด: ถ้าต้องพิสูจน์ว่า PDF ใบไหนถูกออกให้เคสที่เป็นข้อพิพาท ให้เทียบ hash กับแถวใน `documents` / `audit_log` — **โดยไม่ต้องเก็บ PII ไว้เลยแม้แต่น้อย**

---

## 8. คำสั่งทดสอบแบบคัดลอกไปวางได้

```bash
# 1. ถามสัญญาฟิลด์
curl -s "$BASE/v1/templates/$TID/fields?version=2" -H "X-Api-Key: $THINKER_DOCS_API_KEY"

# 2. สร้างเอกสาร (async) → ได้ { documentId, url, sha256, expiresAt }
curl -s -X POST "$BASE/v1/generate" \
  -H "X-Api-Key: $THINKER_DOCS_API_KEY" -H 'Content-Type: application/json' \
  -d "{\"templateId\":\"$TID\",\"version\":2,\"mode\":\"async\",\"data\":{\"case_id\":\"demo-1\"}}"

# 3. ดึงไฟล์ที่เก็บไว้ (ลิงก์จากข้อ 2 หมดอายุ อันนี้สดเสมอ)
curl -s "$BASE/v1/documents/$DOCID?download=1" -H "X-Api-Key: $THINKER_DOCS_API_KEY" -o out.pdf
```

`BASE` คือโฮสต์ on-prem **ไม่ใช่** `localhost` เว้นแต่คุณอยู่บนเครื่องเซิร์ฟเวอร์เอง
