Transcripts
ค้นหาข้อความในบทสนทนา — ครอบคลุมทุกสายที่ API key มองเห็น โดยไม่ต้องรู้ล่วงหน้าว่าอยู่สายไหน
Account key: ทุก request ในหน้านี้ต้องส่ง
X-Business-Id: <business id>เพื่อเลือกธุรกิจที่จะค้น (ถ้าไม่ส่งจะได้400 business_required) ส่วน business key จะไม่อ่าน header นี้ เพราะผูกกับธุรกิจเดียวอยู่แล้ว ดูเพิ่มที่ เริ่มต้นใช้งาน → API key ระดับบัญชี
GET /transcripts/search
ค้นหา turn ที่มีการพูดข้อความนั้น ครอบคลุมทุกสายที่ API key มองเห็น พร้อมแนบ turn รอบข้างมาด้วยเพื่อให้ยกไปอ้างอิงได้จริง
แพ็กเกจ: ตอบ
403 feature_not_in_packageถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ
นี่คือการค้นแบบ global — ขอบเขตคือทุกสายของธุรกิจที่ยังไม่ถูกลบ ไม่ใช่สายใดสายหนึ่ง คำตอบที่ได้จึงเป็น "ประโยคที่มีคนพูด" ไม่ใช่ "รายชื่อสาย": แต่ละ item คือ 1 turn ที่ตรง เรียงจากสายใหม่สุดก่อน
เจอคำว่า ไม่พอใจ อย่างเดียวยังบอกไม่ได้ว่ามาจากไหน — ลูกค้าพูดเอง กับแอดมินถามว่าไม่พอใจไหม เป็นคนละเรื่องกัน ผลลัพธ์จึงมี before / after ติดมาด้วยเสมอ
Scopes: view_recording_all · view_recording_self
ทำงานอย่างไร และทำไมจึงเป็น substring
ทำงาน 2 ขั้น เพราะขั้นเดียวตอบไม่ครบ
- สายไหน — ระบบเก็บข้อความทั้งสายต่อกันเป็นก้อนเดียวและทำดัชนีไว้แล้ว จึงคัดจากสายหลักหมื่นเหลือแค่หน้าเดียวได้ในการค้นครั้งเดียว
- turn ไหน — ข้อความก้อนนั้นตั้งใจไม่เก็บเส้นแบ่ง turn จึงบอกไม่ได้ว่าใครพูด ระบบจึงเปิดไฟล์บทสนทนาของสายในหน้านี้เท่านั้น เพื่อหา turn, ผู้พูด, turn รอบข้าง และช่วงตัวอักษรที่ตรง
ลำดับนี้คือสิ่งที่ทำให้ค้นได้เร็ว: ถ้าไล่เปิดบทสนทนาทุกสายตรง ๆ การค้นแต่ละครั้งจะต้องอ่านข้อมูลทั้งธุรกิจ
ทำไมไม่ค้นเป็นคำ ภาษาไทยไม่มีช่องว่างระหว่างคำ การตัดคำจึงไม่แม่นพอสำหรับข้อมูลนี้ — วิธีที่ใช้ได้คือค้นแบบ substring คือหา q ตรงไหนของข้อความก็ได้ ผลที่ตามมาคือ q จะไปตรงกลางคำก็ได้ ซึ่งเป็นสิ่งที่ต้องการสำหรับภาษาไทย
% และ _ ไม่ใช่ wildcard ระบบค้นสองตัวนี้แบบตัวอักษรตรง ๆ ทั้ง 2 ขั้น (ลด 50% หาคำว่า "ลด 50%" ไม่ใช่ "ลด 50 อะไรก็ได้") จึงไม่มีกรณีได้หน้าว่างทั้งที่ nextCursor ยังเดินต่อ
วันที่และเวลา
กติกาเดียวกับทั้ง API v1 — ถ้าค่านั้นบอก timezone มาเอง ระบบใช้ตามนั้น ถ้าไม่บอก ระบบอ่านตาม tz (ค่าเริ่มต้น 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 | เทียบตรงกับ key ของพนักงาน คือ 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 เพราะ AI เขียนป้ายอารมณ์/น้ำเสียงเป็นข้อความไทยอิสระ (หงุดหงิด, ไม่พอใจเล็กน้อย) ถ้าเทียบแบบต้องตรงทุกตัวอักษร จะได้คำตอบว่า "ไม่มี" กับค่าจริงเกือบทุกค่า
ไม่มีพารามิเตอร์สำหรับจำกัดการค้นให้อยู่ในสายเดียว — ถ้ารู้ 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,
"customerScore": 35,
"customerScoreKind": "service",
"customerReadinessScore": null,
"customerSatisfactionScore": 35,
"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/unknownscore/customerScore/customerScoreKindคือคะแนนเทเลและคะแนนลูกค้าของสาย ค่าเดียวกับที่GET /recordingsคืนมา —customerScoreกับcustomerScoreKindเป็นnullเมื่อสายนั้นยังไม่มีคะแนนลูกค้า (ดู Recordings →customerScore)customerReadinessScore/customerSatisfactionScoreคือคะแนนลูกค้าทั้ง 2 ฝั่ง (ความพร้อมซื้อ · ความพึงพอใจ) แบบเดียวกับGET /recordings: ฝั่งที่customerScoreKindบอกเท่ากับcustomerScoreอีกฝั่งเป็นnullเมื่อสายนั้นไม่มีอะไรให้วัด — เหมือนตัวอย่างด้านบนที่เป็นสายแจ้งปัญหาซึ่งไม่มีเรื่องซื้อขายagent.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 เพราะการนับสายที่ตรงทั้งหมดแบบไม่จำกัดคือส่วนเดียวของการค้นนี้ที่ใช้เวลาได้ไม่มีขอบบน — เมื่อของจริงเกิน จะได้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
- API key ที่มี
view_recording_allค้นได้ทุกสายของธุรกิจนั้น - API key ที่มีแต่
view_recording_selfค้นได้เฉพาะสายที่ผูกกับผู้ใช้เจ้าของ key — คำว่า "global" จึงหมายถึงทุกสายที่ API key นั้นมองเห็น ไม่ใช่ทุกสายในระบบ - สายที่อยู่ในถังขยะ (soft-delete) ไม่ถูกค้น
- Account key ต้องส่ง
X-Business-Idเพื่อบอกว่าจะค้นธุรกิจไหน · ถ้าไม่ส่งจะได้400 business_required· ถ้าส่งธุรกิจที่เจ้าของ key ไม่ได้เป็นสมาชิกจะได้403 forbidden_business· Business key ไม่ต้องส่ง เพราะผูกกับธุรกิจเดียวอยู่แล้ว
ข้อผิดพลาด
| 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 | ปัญหาที่ตัว API key |
insufficient_scope | 403 | API key ไม่มี view_recording_all และไม่มี view_recording_self |
forbidden_business | 403 | account key ชี้ไปที่ธุรกิจที่เจ้าของ key เข้าไม่ถึง |