Transcripts
ค้นหาข้อความในบทสนทนา — ครอบทุกสายที่ token มองเห็น โดยไม่ต้องรู้ล่วงหน้าว่าอยู่สายไหน
Account key: ทุก request ในหน้านี้ต้องส่ง
X-Business-Id: <business id>เพื่อเลือก business ที่จะค้น (ถ้าไม่ส่งจะได้400 business_required) ส่วน business key ไม่สนใจ header นี้เพราะผูกกับ business เดียวอยู่แล้ว ดูเพิ่มที่ Getting started → API Key ระดับบัญชี
GET /transcripts/search
ค้นหา turn ที่มีการพูดข้อความนั้น ครอบทุกสายที่ token มองเห็น พร้อมแนบ turn รอบข้างมาด้วยเพื่อให้ยกไปอ้างอิงได้จริง
นี่คือการค้นแบบ global — ขอบเขตคือทุกสายของ business ที่ยังไม่ถูกลบ ไม่ใช่สายใดสายหนึ่ง คำตอบที่ได้จึงเป็น "ประโยคที่ถูกพูด" ไม่ใช่ "รายชื่อสาย": 1 item = 1 turn ที่ตรง เรียงจากสายใหม่สุดก่อน
การเจอคำว่า ไม่พอใจ เฉย ๆ ระบุที่มาไม่ได้ — ลูกค้าพูดเองกับแอดมินถามว่าไม่พอใจไหม คือคนละเรื่องกัน จึงต้องมี before / after ติดมาด้วยเสมอ
Scopes: view_recording_all · view_recording_self
ทำงานอย่างไร และทำไมจึงเป็น substring
ทำงาน 2 ขั้น เพราะขั้นเดียวตอบไม่ครบ
- สายไหน — ใช้คอลัมน์ transcript แบบแบน (ข้อความทั้งสายต่อกันเป็นก้อนเดียว) ที่มี
pg_trgmGIN index รองรับ คัดจากสายหลักหมื่นเหลือแค่หน้าเดียวด้วยการอ่าน index ครั้งเดียว - turn ไหน — คอลัมน์นั้นตั้งใจไม่เก็บเส้นแบ่ง turn จึงบอกไม่ได้ว่าใครพูด ระบบจึงเปิดไฟล์บทสนทนาของสายในหน้านี้เท่านั้น เพื่อหา turn, ผู้พูด, turn รอบข้าง และช่วงตัวอักษรที่ตรง
ลำดับนี้คือทั้งหมดของการออกแบบ: ถ้าไล่เปิดไฟล์บทสนทนาตรง ๆ จะกลายเป็นการสแกนทั้ง bucket ต่อการค้นหนึ่งครั้ง
ทำไมไม่ค้นเป็นคำ ภาษาไทยไม่มีช่องว่างระหว่างคำ tsvector จึงตัดคำไม่ได้และใช้ไม่ได้จริงกับข้อมูลนี้ — วิธีที่ใช้ได้คือเทียบแบบ substring (ILIKE บน trigram index) ผลที่ตามมาคือ q จะไปตรงกลางคำก็ได้ ซึ่งเป็นสิ่งที่ต้องการสำหรับภาษาไทย
% และ _ ไม่ใช่ wildcard ทั้งสองตัวเป็น wildcard ของ ILIKE แต่ระบบ escape ให้ก่อนเสมอ จึงถูกค้นแบบตัวอักษรตรง ๆ (ลด 50% หาคำว่า "ลด 50%" ไม่ใช่ "ลด 50 อะไรก็ได้") ถ้าปล่อยไว้ดิบ ขั้นที่ 1 จะเจอสายที่ขั้นที่ 2 ไม่มีทางเจอ ผลคือได้หน้าว่างทั้งที่ nextCursor ยังเดินต่อ — อ่านเหมือน "ไม่มีผลลัพธ์" ทั้งที่มี
วันที่และเวลา
กติกาเดียวกับทั้ง API v1 — ถ้าค่านั้นบอก timezone มาเอง ระบบใช้ตามนั้น ถ้าไม่บอก ระบบอ่านตาม tz (default Asia/Bangkok)
2026-08-01= ทั้งวันตามtz·dateFromเริ่ม00:00:00.000·dateToจบ23:59:59.999(รวมวันที่ระบุด้วย)2026-08-01T08:00:00Zหรือ+07:00= ใช้ตามที่ส่งมาเป๊ะ · ถ้าใช้ offset ใน query string ควร encode+เป็น%2B- วันที่ผิดรูปหรือไม่มีอยู่จริงตอบ
400 validation_failed·tzที่ไม่รู้จักถอยไปใช้Asia/Bangkokไม่ใช่ error
ตารางเต็มอยู่ที่ Recordings → วันที่และเวลา
พารามิเตอร์
| Param | Type | Note |
|---|---|---|
q | string 2-100 | required — เทียบแบบตรงตัว ไม่สนตัวพิมพ์เล็กใหญ่ (% และ _ ไม่ใช่ wildcard) · ตัดช่องว่างหัวท้ายก่อนนับความยาว |
dateFrom | วันที่หรือ ISO 8601 | optional — ดู วันที่และเวลา |
dateTo | วันที่หรือ ISO 8601 | optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น) |
tz | IANA timezone | default Asia/Bangkok — ใช้เฉพาะตอนขอบเขตไม่ได้ระบุ timezone มาเอง |
agentExtension | string 1-120 | เทียบตรงกับ COALESCE(callcenterNumber, callcenterName) (key เดียวกับที่ /recordings/agents คืน) |
phone | string 1-40 | เทียบเบอร์ที่ normalize แล้วแบบตรงตัว หรือ เทียบบางส่วนกับเบอร์ดิบ |
sentiment | string 1-60 | เก็บเฉพาะ turn ที่อารมณ์ หรือ น้ำเสียงมีข้อความนี้อยู่ (substring ไม่สนตัวพิมพ์) |
limit | int 1-50 | default 20 — จำนวน turn ต่อหน้า |
context | int 0-5 | default 2 — จำนวน turn รอบข้างที่แนบมาด้านละเท่านี้ |
cursor | string | ค่าทึบ — ส่ง nextCursor ของหน้าก่อนกลับมา ดู การแบ่งหน้า |
sentiment เทียบแบบ substring เพราะป้ายอารมณ์/น้ำเสียงถูกเขียนเป็นข้อความไทยอิสระ (หงุดหงิด, ไม่พอใจเล็กน้อย) การเทียบแบบเท่ากันเป๊ะจะตอบว่า "ไม่มี" กับค่าจริงเกือบทุกค่า
ไม่มีพารามิเตอร์สำหรับจำกัดการค้นให้อยู่ในสายเดียว — ถ้ารู้ callId อยู่แล้วให้ไปที่ Recordings → GET /recordings/detail ซึ่งคืนทุก turn ของสายนั้น
ตัวอย่าง
curl "https://phone.mcloud.co.th/api/v1/transcripts/search?q=%E0%B9%84%E0%B8%A1%E0%B9%88%E0%B8%9E%E0%B8%AD%E0%B9%83%E0%B8%88&dateFrom=2026-08-01&limit=20" \
-H "Authorization: Bearer crk_..." \
-H "X-Business-Id: <business uuid, account keys only>"
{
"query": "ไม่พอใจ",
"items": [
{
"callId": "0192b9...",
"callAt": "2026-08-14T03:22:11.000Z",
"direction": "in",
"customerPhone": "+66812345678",
"customerPhoneNormalized": "0812345678",
"agent": { "extension": "12110", "name": "Somchai" },
"score": 62,
"before": [
{ "index": 41, "speaker": "แอดมิน", "side": "admin", "startMs": 181000, "endMs": 184200,
"text": "ขออภัยด้วยนะคะ", "textOffset": 0, "textTruncated": false, "tone": "polite", "emotion": "neutral" }
],
"turn": {
"index": 42, "speaker": "ลูกค้า", "side": "customer", "startMs": 184200, "endMs": 189500,
"text": "ผมไม่พอใจกับบริการรอบนี้มาก", "textOffset": 0, "textTruncated": false,
"tone": "angry", "emotion": "negative",
"highlight": { "start": 2, "length": 7 }
},
"after": [
{ "index": 43, "speaker": "แอดมิน", "side": "admin", "startMs": 189500, "endMs": 193000,
"text": "รบกวนเล่าให้ฟังหน่อยได้ไหมคะ", "textOffset": 0, "textTruncated": false, "tone": "polite", "emotion": "neutral" }
]
}
],
"limit": 20,
"context": 2,
"matchedCalls": 37,
"matchedCallsCapped": false,
"callsScanned": 25,
"nextCursor": "eyJjYWxsQXQiOiIyMDI2LTA4LTE0..."
}
อ่านผลลัพธ์อย่างไร
- 1 item = 1 turn ที่ตรง เรียงตามลำดับในบทสนทนา · turn ที่พูดคำนั้น 3 ครั้งยังนับเป็น item เดียว เพราะเป็นสิ่งที่ลูกค้าพูดครั้งเดียว และการแยกเป็น 3 บรรทัดจะดันสายอื่นตกหน้าไป
- ผลลัพธ์เรียงจากสายใหม่สุดก่อน (
callAtมากไปน้อย แล้วปิดท้ายด้วย id เพื่อไม่ให้แถวซ้ำข้ามหน้า) highlight.start/highlight.lengthเป็นตำแหน่งภายในtextที่คืนมา ไม่ใช่ใน turn เต็ม: turn ที่ตรงจำกัดไว้ที่ 1200 ตัวอักษร turn รอบข้าง 400 ตัวอักษร · เมื่อtextTruncatedเป็นtrueให้ดูtextOffsetว่าช่วงนั้นเริ่มที่ตัวอักษรที่เท่าไรของ turn เต็ม (highlightจึงชี้ตัวอักษรชุดเดิมเสมอ ไม่ว่าจะถูกตัดหรือไม่)speakerคือคำเรียกที่ธุรกิจตั้งไว้เองสำหรับฝั่งนั้น (ตั้งที่/app/prompt) ค่าเริ่มต้นจึงเป็นภาษาไทย ·sideเป็นค่าที่เครื่องอ่านได้:admin/customer/unknownagent.extension/agent.nameคือค่าที่ประทับไว้บนสายตอนดึงเข้าระบบ ไม่ใช่ชื่อที่แอดมินตั้งทับภายหลัง (ถ้าต้องการชื่อที่ใช้แสดงผลให้ดูที่/recordings/agents)indexคือเลข turn ที่ผู้เขียนไฟล์กำหนด — เป็นตัวเดียวกับที่GET /recordings/detailใช้ จึงอ้างอิงข้ามกันได้- สายที่ยังไม่มีไฟล์บทสนทนา หรือไฟล์อ่านไม่ได้ จะถูกข้ามเงียบ ๆ (ยังนับใน
callsScanned)
การแบ่งหน้า
การแบ่งหน้าเป็น keyset cursor ไม่ใช่ offset — และมีข้อเดียวที่ต้องจำ: ตามไปจนกว่า nextCursor จะเป็น null
| กติกา | ความหมาย |
|---|---|
nextCursor เป็น null | จบแล้วจริง ๆ ไม่มีอะไรอยู่ข้างหลังอีก |
nextCursor มีค่า | ยังมีต่อ ส่งค่านั้นกลับมาที่ cursor แล้วยิงซ้ำ พารามิเตอร์อื่นต้องเหมือนเดิมทุกตัว |
หน้าได้ไม่เต็ม limit หรือ items ว่างเปล่า | ห้ามตีความว่าจบ — แต่ละคำขอเปิดไฟล์บทสนทนาได้มากสุด 25 สาย (callsScanned) ถ้างบนี้หมดก่อนที่หน้าจะเต็ม ระบบคืนเท่าที่ได้พร้อม nextCursor ที่เดินต่อ · การหยุดเพราะหน้าหนึ่งได้น้อยคือวิธีที่ทำให้ผลลัพธ์หายไปเงียบ ๆ |
| สายเดียวมีผลลัพธ์เกินหนึ่งหน้า | cursor จำได้ว่าไปถึง turn ไหนของสายนั้น จึงเดินต่อภายในสายเดิม สายนั้นถูกไล่จนครบ ไม่ถูกตัดทิ้ง |
| cursor ถอดรหัสไม่ได้ | 400 invalid_cursor — cursor เป็นค่าทึบ อย่าแก้ อย่าประกอบเอง ส่งกลับมาตามที่ได้รับ |
cursor เป็น base64url ซึ่งปลอดภัยกับ URL อยู่แล้ว จึงส่งกลับได้ตรง ๆ โดยไม่ต้อง percent-encode
วนจนครบด้วย shell
ต้องมี jq
TOKEN="crk_..."
BASE="https://phone.mcloud.co.th/api/v1/transcripts/search"
Q=$(printf '%s' "ไม่พอใจ" | jq -sRr @uri)
CURSOR=""
while : ; do
PAGE=$(curl -s "$BASE?q=$Q&dateFrom=2026-08-01&limit=50${CURSOR:+&cursor=$CURSOR}" \
-H "Authorization: Bearer $TOKEN")
echo "$PAGE" | jq -c '.items[] | { callId, callAt, text: .turn.text }'
CURSOR=$(echo "$PAGE" | jq -r '.nextCursor // empty')
[ -n "$CURSOR" ] || break
done
วนจนครบด้วย JavaScript
const BASE = 'https://phone.mcloud.co.th/api/v1/transcripts/search'
const headers = { Authorization: 'Bearer crk_...' }
let cursor = null
const hits = []
do {
const url = new URL(BASE)
url.searchParams.set('q', 'ไม่พอใจ')
url.searchParams.set('dateFrom', '2026-08-01')
url.searchParams.set('limit', '50')
if (cursor) url.searchParams.set('cursor', cursor)
const res = await fetch(url, { headers })
if (!res.ok) throw new Error((await res.json()).error.code)
const page = await res.json()
// หน้านี้อาจว่างได้ ทั้งที่ยังมีผลลัพธ์อยู่ข้างหลัง — เงื่อนไขจบคือ nextCursor เท่านั้น
hits.push(...page.items)
cursor = page.nextCursor
} while (cursor)
console.log(`เจอ ${hits.length} turn`)
ตัวเลข 3 ตัวที่ต้องไม่อ่านผิด
| ฟิลด์ | คืออะไร | ไม่ใช่อะไร |
|---|---|---|
matchedCalls | จำนวนสายที่มีข้อความนี้อยู่ | ไม่ใช่จำนวน turn และไม่ใช่ยอดรวมสำหรับแบ่งหน้า |
matchedCallsCapped | true เมื่อจำนวนจริงมากกว่าเพดาน 500 | — |
callsScanned | จำนวนสายที่เปิดไฟล์บทสนทนาในคำขอนี้ สูงสุด 25 | ไม่ใช่จำนวนสายที่ตรงทั้งหมด |
matchedCallsเป็นสัญญาณว่า "เรื่องนี้กระจายแค่ไหน" · เพดานอยู่ที่ 500 เพราะCOUNT(*)แบบไม่มีเพดานบน trigram scan คือส่วนเดียวของ query นี้ที่ไม่มีขอบบน — เมื่อของจริงเกิน จะได้matchedCalls: 500คู่กับmatchedCallsCapped: true- ห้ามคำนวณจำนวนหน้าจาก
matchedCallsสายหนึ่งมีได้หลาย turn ที่ตรงceil(matchedCalls / limit)จึงผิดทั้งขึ้นและลง · เงื่อนไขจบมีอย่างเดียวคือnextCursor === null sentimentไม่มีผลกับmatchedCallsเพราะป้ายอารมณ์อยู่ในไฟล์บทสนทนา ไม่ใช่คอลัมน์ในฐานข้อมูล จึงกรองได้เฉพาะระดับ turn ในขั้นที่ 2 เท่านั้น · ตัวเลขนี้จึงตอบเสมอว่า "มีกี่สายที่พูดคำนี้" ไม่ใช่ "กี่สายที่ผ่านตัวกรองทั้งหมด"
สิทธิ์และขอบเขตที่มองเห็น
ต้องมี view_recording_all หรือ view_recording_self อย่างน้อยหนึ่งอย่าง (manage_all ครอบทั้งหมด) ถ้าไม่มีจะได้ 403 insufficient_scope
- token ที่มี
view_recording_allค้นได้ทุกสายของ business นั้น - token ที่มีแต่
view_recording_selfค้นได้เฉพาะสายที่ผูกกับผู้ใช้เจ้าของ token — คำว่า "global" จึงหมายถึงทุกสายที่ token นั้นมองเห็น ไม่ใช่ทุกสายในระบบ - สายที่อยู่ในถังขยะ (soft-delete) ไม่ถูกค้น
- Account key ต้องส่ง
X-Business-Idเพื่อบอกว่าจะค้น business ไหน · ไม่ส่ง =400 business_required· ส่ง business ที่เจ้าของ key ไม่ได้เป็นสมาชิก =403 forbidden_business· Business key ไม่ต้องส่ง เพราะผูกกับ business เดียวอยู่แล้ว
Error
| Code | Status | เมื่อไร |
|---|---|---|
validation_failed | 400 | q สั้นกว่า 2 หรือยาวเกิน 100, ไม่ได้ส่ง q, limit/context นอกช่วง, วันที่ผิดรูป — response มี issues บอกว่าฟิลด์ไหนผิด |
invalid_cursor | 400 | cursor ถอดรหัสไม่ได้ หรือรูปร่างข้างในไม่ถูก |
business_required | 400 | account key ที่ไม่ได้ส่ง X-Business-Id |
missing_token · invalid_token · token_disabled | 401 | ปัญหาที่ตัว token |
insufficient_scope | 403 | token ไม่มี view_recording_all และไม่มี view_recording_self |
forbidden_business | 403 | account key ชี้ไปที่ business ที่เจ้าของ key เข้าไม่ถึง |
