Recordings
หน่วยข้อมูลหลักของระบบ — บันทึกการสนทนาหนึ่งสาย พร้อม transcript + summary
Account key: ทุก request ด้านล่างต้องส่ง
X-Business-Id: <business id>เพื่อเลือก business ที่จะทำงานด้วย (ถ้าไม่ส่งจะได้400 business_required) ส่วน business key ไม่สนใจ header นี้เพราะผูกกับ business เดียวอยู่แล้ว ดูเพิ่มที่ Getting started → API Key ระดับบัญชี
วันที่และเวลา
ทุกพารามิเตอร์ที่เป็นวัน/เวลาในหน้านี้รับ 3 รูปแบบ กติกาเดียวคือ — ถ้าค่านั้นบอก timezone มาเอง ระบบใช้ตามนั้น ถ้าไม่บอก ระบบอ่านตาม tz (default Asia/Bangkok)
| ที่ส่ง | ระบบอ่านเป็น |
|---|---|
2026-05-01 | ทั้งวันตาม tz — ขอบเริ่ม (dateFrom / callAt) = 00:00:00.000, ขอบท้าย (dateTo) = 23:59:59.999 (รวมวันที่ระบุด้วย) |
2026-05-01T08:00:00 | 08:00 น. ตาม tz |
2026-05-01T08:00:00Z | ใช้ตามที่ส่งมาเป๊ะ (UTC) |
2026-05-01T08:00:00+07:00 | ใช้ตามที่ส่งมาเป๊ะ (offset ที่ระบุ) |
ขอบเขตที่ได้จึงตรงกับ bucket วัน/ชั่วโมงที่ตอบกลับมาเสมอ เพราะอ่านด้วยเขตเวลาเดียวกัน
- ค่าวันที่ผิดรูปหรือไม่มีอยู่จริง (
2026-02-30) ตอบ400 validation_failedพร้อมบอกว่าฟิลด์ไหนผิด tzที่ไม่รู้จักจะถอยไปใช้Asia/Bangkokไม่ใช่ error- ถ้าใช้ offset แบบ
+07:00ใน query string ควร encode+เป็น%2B(ถ้าลืม ระบบเดาให้ได้ แต่อย่าพึ่งพา) tzมีทั้งใน query string, JSON body (POST /recordings) และ form field (POST /recordings/analyze) — ถ้าไม่อยากพึ่ง default ให้ระบุมาตรง ๆ ได้ทุกที่
GET /recordings
List recording แบบ paginated
Scopes: view_recording_all · view_recording_self
| Param | Type | Note |
|---|---|---|
limit | int 1-200 | default 20 |
offset | int | default 0 |
direction | "in" | "out" | optional |
sourceProvider | threecx | dtac | upload | minio | optional |
callDateFrom | วันที่หรือ ISO 8601 | optional — ดู วันที่และเวลา |
callDateTo | วันที่หรือ ISO 8601 | optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น) |
tz | IANA timezone | default Asia/Bangkok — ใช้เฉพาะตอน callDateFrom/callDateTo ไม่ได้ระบุ timezone มาเอง |
customerPhone | string (exact match) | optional |
customerPhoneNormalized | string (exact match) | optional |
tag | normal | short | wrong_or_spam | no_answer | other | none | none = ยังไม่ติด tag |
scoreMin | int 0-100 | เฉพาะ row ที่มี score |
scoreMax | int 0-100 | เฉพาะ row ที่มี score |
search | string | ilike ครอบ dataId, customerPhone, customerPhoneNormalized, callcenterNumber, callcenterName |
sortBy | callAt | duration | score | default callAt |
sortDir | asc | desc | default desc; null อยู่ท้ายสุด, tiebreak callAt desc |
agentKey | string, ส่งซ้ำได้ (สูงสุด 500) | เอาเฉพาะสายของ agent เหล่านี้ — เทียบกับ COALESCE(callcenterNumber, callcenterName) (key เดียวกับที่ /recordings/agents คืน) เช่น ?agentKey=%2B66804972699&agentKey=Somchai |
curl "https://phone.mcloud.co.th/api/v1/recordings?limit=10&direction=in&sortBy=score&sortDir=desc" \
-H "Authorization: Bearer crk_..."
{
"items": [
{
"id": "0192b9...",
"dataId": "rec_2026_05_20_001",
"callAt": "2026-05-20T08:14:00+07:00",
"direction": "in",
"customerPhone": "+66812345678",
"customerPhoneNormalized": "0812345678",
"tag": "normal",
"score": 86,
"processingStatus": "completed",
"audioUrl": "/api/recording/audio/0192b9...",
"transcriptUrl": "/api/recording/transcript/0192b9...",
"summaryUrl": "/api/recording/summary/0192b9...",
"cost": { "chargedThb": 2.4132, "currency": "THB" }
}
],
"total": 1284,
"limit": 10,
"offset": 0
}
cost — เครดิตที่สายนี้ใช้ไป
ทุกแถวของ GET /recordings และ GET /recordings/{id} มี cost.chargedThb — ยอดบาทสุทธิที่ถูกหักจากกระเป๋าสำหรับสายนั้น (ทุกการเรียก AI ของสายนั้นรวมกัน หักด้วยการคืนเงินถ้ามี) ตัวเลขนี้อ่านจากบัญชีเครดิตโดยตรง ไม่ได้คำนวณใหม่ จึงตรงกับยอดที่ถูกหักจริงเสมอ
ถ้าธุรกิจอยู่ใต้พาร์ทเนอร์ กระเป๋าที่ถูกหักคือกระเป๋ารวมของพาร์ทเนอร์ ใช้ตัวเลขนี้ตั้งบิลต่อให้ลูกค้าของคุณเองได้เลย
0 ไม่ได้แปลว่าฟรี — แปลว่า "ยังไม่ถูกหัก" ได้ด้วย เช่นตอน AI ยังไม่ได้รัน ให้อ่านคู่กับ processingStatus เสมอ แล้วนับเป็นยอดเรียกเก็บเมื่อ processingStatus เป็น completed แล้วเท่านั้น
GET /recordings/stats
รวมสถิติ call recording ตามช่วงเวลา — ยอดรวม, จำนวนสายต่อวัน, การกระจาย score, ชั่วโมงที่สายเข้าหนาแน่น, และ top performer
Scopes: view_recording_all · view_recording_self
| Param | Type | Note |
|---|---|---|
dateFrom | วันที่หรือ ISO 8601 | optional — ดู วันที่และเวลา |
dateTo | วันที่หรือ ISO 8601 | optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น) |
customerPhoneNormalized | string (exact match) | optional |
tz | IANA timezone | default Asia/Bangkok — ใช้แบ่ง bucket วัน/ชั่วโมง และใช้อ่านค่าวันที่ที่ไม่ได้ระบุ timezone มาเอง |
agentKey | string, ส่งซ้ำได้ (สูงสุด 500) | เอาเฉพาะสายของ agent เหล่านี้ (ความหมายเดียวกับ endpoint list) |
curl "https://phone.mcloud.co.th/api/v1/recordings/stats?dateFrom=2026-05-01&dateTo=2026-05-31&tz=Asia/Bangkok" \
-H "Authorization: Bearer crk_..."
{
"totals": {
"total": 1284,
"inbound": 902,
"outbound": 382,
"avgDurationMs": 184320.5,
"longestMs": 1432000,
"avgScore": 78.4,
"firstCallAt": "2026-05-01T08:12:00+07:00",
"lastCallAt": "2026-05-31T19:45:00+07:00"
},
"daily": [{ "day": "2026-05-01", "count": 41 }],
"scoreDistribution": { "good": 612, "mid": 388, "low": 96 },
"tagDistribution": {
"normal": 742,
"short": 96,
"wrong_or_spam": 31,
"no_answer": 161,
"other": 8,
"untagged": 58
},
"peakHours": [{ "hour": 0, "count": 3 }],
"topPerformers": [
{ "name": "Somchai", "calls": 214, "avgDurationMs": 176000, "avgScore": 81.2 }
]
}
scoreDistribution: good ≥ 70, mid 40–69.99, low < 40 (เฉพาะ row ที่มี score)tagDistribution: จำนวนสายต่อแท็กของผู้ตรวจ พร้อมuntaggedสำหรับสายที่ยังไม่ถูกแท็ก ทุกคีย์มีเสมอ เลข 0 จึงแปลว่า "ไม่มี" ไม่ใช่ "ไม่ได้ส่งมา" — ใช้ตอบ "สายไม่รับกี่สาย" ได้โดยไม่ต้องไล่ทั้งรายการpeakHours: มีครบ 24 ชั่วโมง (0–23, เติม 0 ให้ชั่วโมงที่ไม่มีสาย)topPerformers: top 10 callcenter name เรียงตามจำนวนสาย
POST /recordings
Ingest metadata + presigned upload (optional) — idempotent ด้วย dataId
Scopes: manage_call_recording
| Param | Type | Note |
|---|---|---|
dataId | string unique | required |
callAt | วันที่หรือ ISO 8601 | required — ดู วันที่และเวลา; วันล้วนเช่น 2026-05-20 = 2026-05-20T00:00:00 ตาม tz |
tz | IANA timezone | optional, default Asia/Bangkok — บอกว่า callAt ที่ไม่มี offset เป็นเวลาเขตไหน (ถ้า callAt มี offset อยู่แล้วช่องนี้ไม่มีผล) |
direction | "in" | "out" | required |
customerPhone | string | optional |
callcenterNumber | string | optional |
callcenterName | string | optional |
tag | enum | optional |
score | number 0-100 | optional |
note | string | optional |
contentType | string (MIME) | default audio/wav |
audioPresignedUploadUrl | boolean | ขอ presigned URL? |
curl -X POST https://phone.mcloud.co.th/api/v1/recordings \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{
"dataId": "rec_2026_05_20_001",
"callAt": "2026-05-20T08:14:00+07:00",
"direction": "in",
"audioPresignedUploadUrl": true
}'
GET /recordings/{id}
ดึง recording เดียว
Scopes: view_recording_all · view_recording_self
PATCH /recordings/{id}
Review — score / tag / comment / note / link
Scopes: manage_recording_review · manage_recording_link
| Param | Type | Note |
|---|---|---|
score | number 0-100 | optional |
tag | normal | short | wrong_or_spam | no_answer | other | optional |
reviewComment | string | optional |
note | string | optional |
linkedUserId | uuid | null | requires manage_recording_link |
DELETE /recordings/{id}
Soft-delete (ย้ายไป trash, เก็บ 30 วัน)
Scopes: manage_call_recording
POST /recordings/{id}/reextract
สั่ง AI ทำงานใหม่ (ถอดเสียง + สรุป)
Scopes: manage_call_recording
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/$ID/reextract \
-H "Authorization: Bearer crk_..."
POST /recordings/analyze
วิเคราะห์ไฟล์เสียงแบบ synchronous — อัปโหลดเสียง รัน AI แล้วคืนผลลัพธ์ทั้งหมดพร้อมค่าใช้จ่ายที่คิดเงินแล้วในครั้งเดียว (รอจนเสร็จ) ไฟล์จะถูกบันทึกและแสดงใน dashboard ใช้เวลาประมาณ 1–4 นาที ให้ตั้ง client timeout ยาวๆ
Scopes: manage_call_recording
body เป็น multipart/form-data (ไม่ใช่ JSON)
| Param | Type | Note |
|---|---|---|
file | file (multipart) | required — ไฟล์เสียงทุกฟอร์แมต (wav/mp3/m4a/mp4/aac/ogg/opus/webm/flac/amr/wma ฯลฯ), ≤500MB — ไฟล์ใหญ่/ไม่บีบอัดจะถูกบีบเป็น Opus ตอนจัดเก็บโดยไม่เสียคุณภาพ |
direction | "in" | "out" | optional, default "in" |
customerPhone | string | optional |
callcenterName | string | optional |
callcenterNumber | string | optional |
note | string | optional |
callAt | วันที่หรือ ISO 8601 | optional, default = เวลาปัจจุบัน; ไม่ระบุ timezone = อ่านตาม tz — ค่าที่ตีความได้ส่งกลับมาในคีย์ callAt ของ response จะได้ตรวจได้ว่าตรงกับที่ตั้งใจ |
tz | IANA timezone | optional, default Asia/Bangkok — บอกว่า callAt ที่ไม่มี offset เป็นเวลาเขตไหน |
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/analyze \
-H "Authorization: Bearer crk_..." \
-H "X-Business-Id: <business uuid, account keys only>" \
-F "file=@call.wav" \
-F "direction=in" \
-F "customerPhone=+66812345678"
{
"recordingId": "0190f7b2-...",
"dataId": "api-2f1c...",
"callAt": "2026-05-20T01:14:00.000Z",
"status": "completed",
"result": {
"transcript": "แอดมิน: สวัสดีค่ะ...\nลูกค้า: ...",
"segments": [
{ "index": 0, "start_ms": 0, "end_ms": 2400, "speaker": "admin", "text": "สวัสดีค่ะ", "emotion": "neutral", "tone": "polite", "coaching": "" }
],
"summary": "ลูกค้าสอบถามโปรโมชั่น...",
"recommendations": "- [0:50-1:00] ตอนลูกค้าถามเรื่องราคา ควรเสนอ...\n- [2:10-2:25] ช่วงปิดสาย ควรนัดติดตาม...",
"score": 86,
"tag": "normal",
"overallEmotion": "positive",
"overallTone": "friendly"
},
"cost": { "chargedThb": 0.1234, "currency": "THB" },
"durationMs": 73210
}
cost.chargedThb คือยอดที่คิดเงินกับธุรกิจ (ราคาสุทธิ) ระบบจะไม่เปิดเผยต้นทุนดิบ อัตรา หรือรุ่น AI
Error ที่เป็นไปได้: 402 insufficient_credit, 413 audio_file_too_large, 415 audio_unsupported_format, 422 audio_file_required, 503 worker_unavailable, 504 extraction_timeout
GET /recordings/agents
directory ของ callcenter agent — รวม agent ที่พบใน recording (มี calls / lastCallAt จริง) เข้ากับ agent จาก provider ที่ sync มาแต่ยังไม่มีสาย (จะได้ calls: 0 กับ lastCallAt: null) เพื่อให้ agent ที่เพิ่งดึงจาก provider โผล่ก่อนมีสายแรก นำ key ที่ได้ไปใช้เป็น filter agentKey กับ endpoint list และ stats ได้เลย (key คือ COALESCE(callcenterNumber, callcenterName))
Scopes: view_recording_all · view_recording_self (token view_recording_self เห็นเฉพาะ agent ที่ผูกกับ user ตัวเอง)
curl https://phone.mcloud.co.th/api/v1/recordings/agents \
-H "Authorization: Bearer crk_..."
{
"items": [
{
"key": "+66804972699",
"name": "66804972699 FIN4YOU 07",
"number": "+66804972699",
"calls": 412,
"lastCallAt": "2026-06-09T14:13:53.000Z"
},
{
"key": "12128",
"name": "New Agent",
"number": "12128",
"calls": 0,
"lastCallAt": null
}
],
"total": 8
}
POST /recordings/agents/sync
ดึง roster ของ agent ทั้งหมดจาก provider โทรศัพท์ที่ active ของ business (directory extension ของ 3CX / รายชื่อ user ของ DTAC) เข้า agent directory หลังจากนี้ GET /recordings/agents จะคืน agent ครบทุกคน รวมถึงคนที่ยังไม่มีสาย เทียบเท่าปุ่ม "ดึงจาก provider" ในหน้าทีมของแอป เป็นการ เรียกสดไปที่ provider (login + list) จึงอาจใช้เวลาสองสามวินาที
Scopes: manage_recording_link
| Field | ความหมาย |
|---|---|
synced | จำนวน agent ที่ upsert เข้า directory |
providers | จำนวน provider ที่ดึง — 0 = ยังไม่ได้ตั้งค่า provider |
failed | provider ที่ล้มเหลว (เช่น ["dtac"]) — ล้มบางส่วน ไม่ fatal |
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/agents/sync \
-H "Authorization: Bearer crk_..."
{ "synced": 24, "providers": 1, "failed": [] }
PATCH /recordings/agents/{extension}
ตั้งหรือล้างป้ายชื่อ (label) ของ callcenter agent — เหมือนการแก้ label บนการ์ดในหน้าทีม โดย {extension} คือ number ของ agent ที่ได้จาก endpoint list (ต้อง URL-encode เช่น +66804972699 → %2B66804972699) ป้ายชื่อใหม่จะ cascade ไปยังทุก recording ที่มี callcenter_number เดียวกัน ทำให้ leaderboard, รายการสาย และหัว player แสดงชื่อใหม่ทันที
ส่ง customName: null เพื่อล้าง override แล้วกลับไปใช้ชื่อที่ provider ตั้งให้
Scope: manage_recording_link
| field | type | notes |
|---|---|---|
customName | string (1–128) | null, required | ป้ายชื่อใหม่ — ส่ง null เพื่อล้าง override |
curl -X PATCH "https://phone.mcloud.co.th/api/v1/recordings/agents/%2B66804972699" \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{"customName":"ทีมขาย A"}'
{
"ok": true,
"extension": "+66804972699",
"customName": "ทีมขาย A",
"effectiveName": "ทีมขาย A"
}
จะคืน 404 extension_not_found เมื่อไม่พบ extension นี้ใน business
GET /recordings/prompt-config
อ่าน config การวิเคราะห์สายด้วย AI ของ business — บริบทธุรกิจที่ AI ใช้เป็น persona, คำอธิบาย tag ทั้ง 4 ประเภท, เกณฑ์การให้คะแนนแบบถ่วงน้ำหนัก และความยาวของผลลัพธ์ (summaryStyle / coachingStyle กับคำสั่งเพิ่มเติมของธุรกิจ) เป็น config เดียวกับที่หน้า /app/prompt ในแอปใช้จัดการ AI จะนำไปประกอบเป็น persona ทุกครั้งที่ถอดเสียง/วิเคราะห์สาย · business ที่ยังไม่เคยตั้งค่าจะได้ค่าเริ่มต้น (คำอธิบาย tag + เกณฑ์รวม 100 คะแนน + ความยาว standard) โดย businessName จะ fallback เป็นชื่อ business
Scopes: manage_recording_prompt · view_recording_all
curl "https://phone.mcloud.co.th/api/v1/recordings/prompt-config" \
-H "Authorization: Bearer crk_..."
{
"businessName": "Acme Co.",
"businessAbout": "ขายอสังหาริมทรัพย์ ลูกค้าเป็นกลุ่มครอบครัว",
"businessProducts": "บ้านเดี่ยว, ทาวน์โฮม, คอนโด",
"tagDescriptions": {
"normal": "บทสนทนาปกติ มีเนื้อหา/ติดต่อธุรกิจจริง",
"short": "สายสั้นมาก ไม่มีเนื้อหา หรือวางสายก่อนพูดจบ",
"wrong_or_spam": "โทรผิด สายขายของ spam สแกม หรือไม่เกี่ยวกับธุรกิจ",
"no_answer": "สายที่ไม่มีคนรับ ไม่มีบทสนทนา (missed call)",
"other": "อื่นๆ ที่ไม่เข้ากลุ่มข้างบน"
},
"scoreCategories": [
{ "label": "การทักทายและมารยาท", "maxPoints": 20 },
{ "label": "การสอบถามข้อมูลครบถ้วน", "maxPoints": 30 },
{ "label": "การจัดการข้อโต้แย้ง", "maxPoints": 20 },
{ "label": "การปิดการสนทนา/นัดหมาย", "maxPoints": 30 }
],
"summaryStyle": "short",
"coachingStyle": "standard",
"summaryInstructions": "บอกด้วยว่าลูกค้าสนใจสินค้าตัวไหน",
"coachingInstructions": "",
"updatedAt": "2026-06-10T04:54:12.745Z",
"updatedBy": null
}
PUT /recordings/prompt-config
บันทึก/อัปเดต config การวิเคราะห์ด้วย AI (1 แถวต่อ 1 business) · ช่องบริบทธุรกิจที่เว้นว่างจะถูกเก็บเป็น null แล้ว businessName จะ fallback เป็นชื่อ business · scoreCategories มีได้ 1–20 หมวด · endpoint ตรวจรูปแบบข้อมูลแต่ไม่บังคับให้ผลรวมน้ำหนักเท่ากับ 100 (ตัว editor ในแอปบังคับให้เอง)
⚠️ endpoint นี้ เขียนทับทั้ง config ยกเว้น 4 ฟิลด์ความยาว (summaryStyle · coachingStyle · summaryInstructions · coachingInstructions) ที่เป็นแบบ ไม่ส่งมา = ไม่แตะของเดิม — ระบบที่เขียนไว้ก่อนมีฟีเจอร์นี้จึงไม่เผลอรีเซ็ตความยาวที่ลูกค้าตั้งไว้ทุกครั้งที่ยิง PUT · ถ้าอยากล้างคำสั่งเพิ่มเติมให้ส่งค่าว่าง "" มาชัด ๆ
Scope: manage_recording_prompt
| field | type | notes |
|---|---|---|
businessName | string (≤200) | ชื่อที่ AI ใช้ — เว้นว่าง → fallback เป็นชื่อ business |
businessAbout | string (≤4000) | ธุรกิจทำเกี่ยวกับอะไร (บริบทให้ AI) |
businessProducts | string (≤4000) | สินค้า/บริการที่พูดถึงในสาย |
tagDescriptions | object, required | 5 คีย์: normal, short, wrong_or_spam, no_answer, other (แต่ละค่า ≤1000) — คีย์ที่ไม่ส่งมาหรือส่งค่าว่างจะถูกเติมด้วยคำอธิบายเริ่มต้นให้อัตโนมัติ และ response ของ GET จะคืนครบทั้ง 5 คีย์เสมอ |
scoreCategories | array 1–20, required | แต่ละหมวด { label (1–200), maxPoints (1–100 จำนวนเต็ม) } |
summaryStyle | short | standard | detailed | ความยาวของ summary — สั้น = 1–2 ประโยค, มาตรฐาน = 2–3 ประโยค (ค่าเดิมของระบบ), ละเอียด = 4–6 ประโยค · ไม่ส่งมาหรือส่งค่าที่ไม่รู้จัก = standard |
coachingStyle | short | standard | detailed | ความยาวของ recommendations — สั้น = 2–3 ข้อ (~400–800 ตัวอักษร), มาตรฐาน = 4–6 ข้อ (~1,000–2,000 ตัวอักษร, ค่าเดิมของระบบ), ละเอียด = 6–8 ข้อ (~2,000–3,500 ตัวอักษร) · ปรับแค่ความยาว กติกาการอ้างหลักฐานและการชี้จุดในสายเหมือนกันทุกแบบ |
summaryInstructions | string (≤2000) | คำสั่งของธุรกิจสำหรับส่วนสรุป — ต่อท้ายกฎกลาง ไม่ได้แทนที่ · ส่งค่าว่างเพื่อล้าง |
coachingInstructions | string (≤2000) | คำสั่งของธุรกิจสำหรับส่วนคำแนะนำ — ต่อท้ายกฎกลาง ไม่ได้แทนที่ · ส่งค่าว่างเพื่อล้าง |
curl -X PUT "https://phone.mcloud.co.th/api/v1/recordings/prompt-config" \
-H "Authorization: Bearer crk_..." \
-H "Content-Type: application/json" \
-d '{
"businessName": "Acme Co.",
"businessAbout": "ขายอสังหาริมทรัพย์ ลูกค้าเป็นกลุ่มครอบครัว",
"businessProducts": "บ้านเดี่ยว, ทาวน์โฮม, คอนโด",
"tagDescriptions": {
"normal": "บทสนทนาปกติ",
"short": "สายสั้นมาก",
"wrong_or_spam": "โทรผิด/สแปม",
"no_answer": "ไม่รับสาย",
"other": "อื่นๆ"
},
"scoreCategories": [
{ "label": "การทักทายและมารยาท", "maxPoints": 50 },
{ "label": "การปิดการสนทนา/นัดหมาย", "maxPoints": 50 }
],
"summaryStyle": "short",
"coachingStyle": "short",
"summaryInstructions": "",
"coachingInstructions": "เน้นเรื่องการปิดการขายและการนัดหมาย"
}'
{ "ok": true }
จะคืน 422 validation_failed เมื่อข้อมูลไม่ถูกต้อง — เช่น ขาด tag key, scoreCategories ว่าง, หรือ maxPoints อยู่นอกช่วง 1–100
GET /recordings/detail
ทุกอย่างของสายเดียว จบในคำขอเดียว — ตัว record พร้อมผลวิเคราะห์ที่เก็บไว้ แปลงเป็น turn ให้เรียบร้อยแล้ว
GET /recordings/{id} คืนแถวข้อมูลกับ URL อีก 3 อันที่ผู้เรียกต้องไปดึงและ parse เอง แถมไฟล์ยังมี 2 รูปแบบตามยุคที่บันทึก · endpoint นี้อ่านไฟล์เหล่านั้นให้ที่ฝั่ง server แล้วคืนมาเป็นโครงสร้างพร้อมใช้ ระบบภายนอก (หรือ tool call ของ LLM) จึงได้คำตอบครบในคำขอเดียว
Scopes: view_recording_all · view_recording_self
| Param | Type | Note |
|---|---|---|
id | uuid | required |
include | รายการคั่นด้วยจุลภาคจาก transcript, summary, coaching, sentiment | default summary — เลือกทีละส่วนเพราะ transcript ก้อนใหญ่ที่สุด |
maxTurns | int 1-2000 | default 200 — นับแยกในแต่ละส่วน · ดู truncated + totalTurns เพื่อรู้ว่าชนเพดานหรือยัง |
curl "https://phone.mcloud.co.th/api/v1/recordings/detail?id=0192b9...&include=summary,transcript&maxTurns=100" \
-H "Authorization: Bearer crk_..."
{
"call": {
"id": "0192b9...",
"dataId": "rec_2026_05_20_001",
"callAt": "2026-05-20T01:14:00.000Z",
"direction": "in",
"customerPhone": "+66812345678",
"tag": "normal",
"score": 86,
"durationMs": 214000,
"processingStatus": "completed",
"aiProcessed": true
},
"include": ["summary", "transcript"],
"summary": {
"text": "ลูกค้าสอบถามโปรโมชั่นบ้านเดี่ยว...",
"recommendations": "- [0:50-1:00] ตอนลูกค้าถามเรื่องราคา ควรเสนอ...",
"keyPoints": ["สนใจโครงการ A", "ขอใบเสนอราคา"],
"concerns": ["กังวลเรื่องดอกเบี้ย"],
"nextSteps": ["ส่งใบเสนอราคาภายในวันนี้"],
"score": 86,
"tag": "normal",
"overallEmotion": "positive",
"overallTone": "friendly"
},
"transcript": {
"turns": [
{ "index": 0, "speaker": "แอดมิน", "side": "admin", "startMs": 0, "endMs": 2400, "text": "สวัสดีค่ะ" },
{ "index": 1, "speaker": "ลูกค้า", "side": "customer", "startMs": 2400, "endMs": 7100, "text": "อยากสอบถามโปรโมชั่นค่ะ" }
],
"totalTurns": 148,
"returnedTurns": 100,
"maxTurns": 100,
"truncated": true
}
}
รูปร่างของอีก 2 ส่วน:
| ส่วน | โครงสร้าง |
|---|---|
coaching | { recommendations, turns[{ index, speaker, side, text, coaching, evidence }], totalTurns, returnedTurns, truncated } |
sentiment | { overallEmotion, overallTone, emotionCounts, toneCounts, turns[{ index, speaker, side, emotion, tone }], totalTurns, returnedTurns, truncated } |
callคือ object เดียวกับที่GET /recordings/{id}คืน บวกaiProcessed— boolean ที่บอกว่าสายนี้ผ่านการวิเคราะห์ด้วย AI สำเร็จอย่างน้อยหนึ่งครั้งแล้ว · ใช้โมเดลไหน ผู้ให้บริการใด และต้นทุนเท่าไร ไม่เคยถูกส่งกลับ —aiProcessedคือสัญญาณเดียวเกี่ยวกับ AI ใน endpoint นี้- ส่วนที่ขอมาจะเป็น
nullเมื่อไฟล์ผลวิเคราะห์หายไปหรืออ่านไม่ได้ ซึ่งไม่เหมือนกับส่วนที่มีอยู่แต่ว่างเปล่า sideเป็นค่าที่เครื่องอ่านได้:admin/customer/unknown· ส่วนspeakerคือคำเรียกที่ธุรกิจตั้งไว้เองสำหรับฝั่งนั้น (ตั้งที่/app/prompt) ค่าเริ่มต้นจึงเป็นภาษาไทย · ถ้าเป็นบุคคลที่สามที่มีชื่อจริง ระบบจะคงชื่อนั้นไว้และให้side: "unknown"coaching.totalTurnsและsentiment.totalTurnsนับเฉพาะ turn ที่มีคำอธิบายกำกับ (มีคำแนะนำ หรือมีอารมณ์/น้ำเสียง) ไม่ใช่ทุก turn ในสาย · ส่วนtranscript.totalTurnsนับครบทุก turnemotionCounts/toneCountsนับจากทั้งสาย ไม่ใช่แค่หน้า turn ที่คืนมาincludeที่ไม่ใช่ 4 ชื่อนี้จะได้400 validation_failed· สายที่ token มองไม่เห็นจะได้404 not_foundไม่ใช่403เพราะ 403 เท่ากับยืนยันว่า id นั้นมีอยู่จริง
ค้นหาข้อความในบทสนทนา
การค้นว่า "มีใครพูดคำนี้ไว้ตรงไหนบ้าง" ครอบทุกสายที่ token มองเห็น อยู่ที่ GET /transcripts/search — ดูหน้า Transcripts ซึ่งครอบคลุมพารามิเตอร์ทั้งหมด การแบ่งหน้าด้วย cursor และวิธีอ่าน matchedCalls / callsScanned
Insights
6 endpoint อ่านอย่างเดียวใต้ /insights/** ออกแบบให้คำตอบทั้งชุดพอดีกับ 1 response — สำหรับผู้ช่วย AI และแดชบอร์ด ไม่ใช่สำหรับ export ทีละแถว
ทุกตัวเลขใช้นิยามเดียวกับแดชบอร์ดในแอป (การแบ่งสาย 4 ทาง, เกณฑ์รับสาย/สายหลุด, ช่วงคะแนน, funnel) คำตอบที่ได้จาก API นี้กับภาพหน้าจอ /app/dashboard จึงขัดแย้งกันไม่ได้
Scopes (ทั้ง 6 endpoint): view_recording_all · view_recording_dashboard · view_recording_self
token ที่ไม่มี view_recording_all (หรือ manage_all) จะนับเฉพาะสายที่ผูกกับผู้ใช้ของตัวเองเท่านั้น
พารามิเตอร์ร่วม — ทุก insights endpoint รับชุดนี้:
| Param | Type | Note |
|---|---|---|
dateFrom | วันที่หรือ ISO 8601 | optional — ดู วันที่และเวลา |
dateTo | วันที่หรือ ISO 8601 | optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น) |
tz | IANA timezone | default Asia/Bangkok — เป็นเขตเวลาที่ใช้ตัด bucket วัน/ชั่วโมงด้วย |
agentKey | string, ส่งซ้ำได้ (สูงสุด 500) | เอาเฉพาะ agent เหล่านี้ — key เดียวกับ /recordings/agents · /insights/agents ไม่รับพารามิเตอร์นี้ ให้ใช้ search แทน |
ช่วงเวลาถูกตีความอย่างไร
- ไม่ส่งขอบเขตทั้งสองข้าง → ทั้งหมดตั้งแต่ต้น และไม่มีช่วงก่อนหน้าให้เทียบ:
period.previousกับcomparisonทุกตัวจะเป็นnull(การใส่ default 30 วันตรงนี้คือบั๊กที่ทำให้ปุ่ม "ทั้งหมด" กลายเป็นแค่ช่วงหนึ่งเงียบ ๆ) - ส่งแค่
dateTo→ ช่วงจบตรงนั้นและเริ่มก่อนหน้า 30 วัน · ส่งแค่dateFrom→ ช่วงจบที่ปัจจุบัน - นอกนั้น ช่วงเปรียบเทียบยาวเท่ากันและไปจบตรงจุดที่ช่วงนี้เริ่ม
ทุก response เปิดด้วยบล็อกเดียวกันนี้:
{
"period": {
"from": "2026-07-31T17:00:00.000Z",
"to": "2026-08-31T16:59:59.999Z",
"tz": "Asia/Bangkok",
"allTime": false,
"previous": { "from": "2026-06-30T17:00:00.000Z", "to": "2026-07-31T17:00:00.000Z" }
}
}
คำว่า "sentiment" ในที่นี้หมายถึงอะไร ผลอารมณ์รายสายที่ AI ตัดสินอยู่ในไฟล์ผลวิเคราะห์บน object storage ไม่มีคอลัมน์ในฐานข้อมูล จึงรวมยอดด้วย SQL ไม่ได้ และการเปิดไฟล์ทีละสายก็ทำไม่ไหว · ที่ endpoint กลุ่มนี้เรียกว่า sentiment คือคะแนน 0-100 ของ AI ที่แบ่งช่วงที่ 70 / 40 ซึ่งเป็นจุดตัดเดียวกับ score distribution ของแดชบอร์ด · ทุก response บอกไว้ตรง ๆ ด้วย basis: "score" และ thresholds จะได้ไม่ต้องเดาว่ากำลังอ่านตัวไหนอยู่
GET /insights/overview
ภาพรวมพาดหัวของช่วงเวลา: ตัวเลขราว 40 ค่า ไม่มี array และมีช่วงก่อนหน้าที่ยาวเท่ากันวางคู่ไว้ให้ ตอบคำถาม "ดีขึ้นไหม" ได้โดยไม่ต้องยิงคำขอที่สอง
พารามิเตอร์: ชุดร่วมเท่านั้น
curl "https://phone.mcloud.co.th/api/v1/insights/overview?dateFrom=2026-08-01&dateTo=2026-08-31" \
-H "Authorization: Bearer crk_..."
{
"period": { "from": "2026-07-31T17:00:00.000Z", "to": "2026-08-31T16:59:59.999Z", "tz": "Asia/Bangkok", "allTime": false, "previous": { "from": "2026-06-30T17:00:00.000Z", "to": "2026-07-31T17:00:00.000Z" } },
"calls": {
"total": 1284,
"answered": 1102,
"missed": 182,
"split": { "inbound": 812, "outbound": 290, "missedInbound": 151, "missedOutbound": 31 },
"answerRate": 0.8583,
"firstCallAt": "2026-08-01T01:12:00.000Z",
"lastCallAt": "2026-08-31T12:45:00.000Z"
},
"duration": { "talkTimeMs": 203112000, "avgMs": 184320.5, "longestMs": 1432000 },
"sentiment": {
"basis": "score",
"thresholds": { "positive": 70, "neutral": 40 },
"positive": 612, "neutral": 388, "negative": 96, "unscored": 188, "scored": 1096,
"avgScore": 78.4
},
"aiProcessed": {
"completed": 1096, "pending": 12, "transcribing": 3,
"failed": 5, "insufficientCredit": 0, "coverage": 0.8536
},
"comparison": {
"calls": { "previous": 1190, "changePct": 7.9 },
"answered": { "previous": 1004, "changePct": 9.76 },
"missed": { "previous": 186, "changePct": -2.15 },
"talkTimeMs": { "previous": 191400000, "changePct": 6.12 },
"avgScore": { "previous": 76.1, "changePct": 3.02 },
"aiCoverage": { "previous": 0.81, "changePct": 5.38 }
}
}
- 4 ค่าใน
splitแบ่งtotalได้พอดี:total = inbound + outbound + missedInbound + missedOutbound· "สายหลุด" คือคำตัดสินno_answerของ AI (กริ่งดังแต่ไม่มีคนรับ ไม่มีบทสนทนา) ตรงกับที่คนหน้างานเรียกว่าสายที่ไม่ได้รับ duration.talkTimeMsรวมเฉพาะสายที่รับ · สายที่ไม่มีคนรับมีระยะเวลากริ่งซึ่งไม่มีใครได้คุย ถ้านับรวมจะทำให้ชั่วโมงคุยของทุกคนพองเกินจริงanswerRateและcoverageเป็นสัดส่วน 0-1 และเป็นnullเมื่อไม่มีตัวหารให้คำนวณchangePctเป็นnullเมื่อไม่มีอัตราส่วนให้รายงาน — ข้างใดข้างหนึ่งหายไป หรือค่าเดิมเป็นศูนย์ (เพราะ "+100%" จากศูนย์อ่านเหมือนเป็นแนวโน้ม ทั้งที่แค่เพิ่งมีสายแรก)
GET /insights/agents
ตารางรายคนของช่วงเวลา: ปริมาณสาย การแบ่ง 4 ทาง เวลาคุย คะแนนจาก AI และตัวเลขช่วงก่อนหน้าของแต่ละคน ทำให้ตอบ "ใครตกลง" ได้ในการอ่านครั้งเดียว
ตัวตนของ agent คือ COALESCE(callcenterNumber, callcenterName) — key ตัวเดียวกับที่ /recordings/agents คืน จึงสลับไปมาระหว่าง endpoint ได้โดยไม่ต้องมีตารางแปลง · ชื่อที่แสดงคำนวณแบบเดียวกับกระดานทีมในแอปเป๊ะ ๆ: ชื่อที่แอดมินตั้งทับ → ชื่อจาก provider → ชื่อที่ถูกประทับไว้บนสาย
| Param | Type | Note |
|---|---|---|
search | string | ค้นได้ทั้ง extension, ชื่อที่ประทับบนสาย, ชื่อในไดเรกทอรีของ provider และชื่อที่ตั้งทับ |
sortBy | calls | answered | missed | talkTime | avgScore | answerRate | name | default calls |
sortDir | asc | desc | default desc; null อยู่ท้ายสุด, ค่าเสมอกันตัดสินด้วย key ของ agent |
limit | int 1-200 | default 50 (response บอกเพดานไว้ที่ maxLimit) |
offset | int ≥ 0 | default 0 |
endpoint นี้ไม่รับ agentKey — ให้ใช้ search หรืออ่านทั้งหน้าแทน
curl "https://phone.mcloud.co.th/api/v1/insights/agents?dateFrom=2026-08-01&sortBy=avgScore&sortDir=asc&limit=20" \
-H "Authorization: Bearer crk_..."
{
"period": { "…": "เหมือนด้านบน" },
"sentiment": { "basis": "score", "thresholds": { "positive": 70, "neutral": 40 } },
"sort": { "by": "avgScore", "dir": "asc" },
"limit": 20,
"offset": 0,
"maxLimit": 200,
"total": 34,
"items": [
{
"key": "12110",
"agentId": "0192aa...",
"extension": "12110",
"name": "Somchai",
"isActive": true,
"calls": 214,
"answered": 190,
"missed": 24,
"split": { "inbound": 150, "outbound": 40, "missedInbound": 20, "missedOutbound": 4 },
"answerRate": 0.8878,
"talkTimeMs": 37642000,
"avgDurationMs": 176000,
"avgScore": 64.2,
"scored": 188,
"sentiment": { "positive": 60, "neutral": 98, "negative": 30, "unscored": 26 },
"lastCallAt": "2026-08-31T09:14:00.000Z",
"comparison": { "calls": 240, "callsChangePct": -10.83, "talkTimeMs": 41200000, "avgScore": 68.9, "avgScoreChangePct": -6.82 }
}
]
}
totalคือจำนวน agent ที่ไม่ซ้ำกันในช่วงนั้น ใช้คู่กับoffsetเพื่อแบ่งหน้าagentIdและisActiveมาจากไดเรกทอรีของ provider · extension ที่มีอยู่แต่ในประวัติสายจะได้agentId: nullและisActive: falsecomparisonแยกรายแถวและเป็นnullเมื่อดูแบบทั้งหมดตั้งแต่ต้น · คำนวณเฉพาะ agent ที่อยู่ในหน้านี้เท่านั้น
GET /insights/timeseries
หนึ่ง metric แบ่งตามช่วงเวลา พร้อมค่าสรุปของ metric เดียวกันในช่วงก่อนหน้า เพื่อให้กราฟมีอะไรให้เทียบ · bucket ถูกตัดตาม tz ไม่ใช่ UTC — ฮิสโทแกรมรายชั่วโมงของคอลเซ็นเตอร์ในกรุงเทพฯ ถ้าตัดตามเส้น UTC จะทำให้ช่วงเร่งด่วนตอนเช้าไปตกผิด bucket
| Param | Type | Note |
|---|---|---|
metric | calls | duration | sentiment | processed | default calls |
bucket | hour | day | week | month | default day |
บวกกับพารามิเตอร์ร่วม (รวม agentKey ที่ส่งซ้ำได้)
metric | แต่ละจุดมีอะไร |
|---|---|
calls | { bucket, total, inbound, outbound, missedInbound, missedOutbound } |
duration | { bucket, calls, talkTimeMs, avgMs } |
sentiment | { bucket, calls, positive, neutral, negative, unscored, avgScore } |
processed | { bucket, total, completed, pending, failed, coverage } |
curl "https://phone.mcloud.co.th/api/v1/insights/timeseries?metric=calls&bucket=day&dateFrom=2026-08-01&dateTo=2026-08-07" \
-H "Authorization: Bearer crk_..."
{
"period": { "…": "เหมือนด้านบน" },
"metric": "calls",
"bucket": "day",
"sentimentBasis": null,
"maxPoints": 500,
"truncated": false,
"points": [
{ "bucket": "2026-08-01", "total": 41, "inbound": 30, "outbound": 8, "missedInbound": 3, "missedOutbound": 0 },
{ "bucket": "2026-08-02", "total": 36, "inbound": 25, "outbound": 9, "missedInbound": 2, "missedOutbound": 0 }
],
"summary": { "current": 1284, "previous": 1190, "changePct": 7.9 }
}
- รูปแบบป้าย bucket:
hour→2026-08-01T09:00,day→2026-08-01,week→ วันจันทร์ของสัปดาห์นั้น,month→2026-08 - จุดเรียงจากเก่าไปใหม่ สูงสุด 500 จุด (
maxPoints) · ถ้าเกิน ระบบตัดปลายเก่าสุดทิ้งและตั้งtruncatedเป็นtrue— ให้ย่อช่วงหรือขยาย bucket แทนที่จะอ่านกราฟที่ถูกตัดว่าเป็นคำตอบทั้งหมด summary.current/summary.previousคือตัวเลขเดียวที่สรุป metric นั้นทั้งช่วง: จำนวนสาย, เวลาคุยรวมเป็นมิลลิวินาที, คะแนนเฉลี่ย หรือสัดส่วน 0-1sentimentBasisจะเป็น{ basis, thresholds }เฉพาะตอนmetric=sentimentนอกนั้นเป็นnull
GET /insights/topics
สายในช่วงนั้นเรื่องอะไรบ้าง ตามการจัดหมวดของธุรกิจเอง พร้อมช่วงก่อนหน้าวางคู่ทุกแถว
หมวดที่ใช้คือ tag ของสาย — คลาสที่ AI ตัดสินจากคำอธิบายที่ธุรกิจเขียนไว้เองที่ /app/prompt และทุกแถวคืนคำอธิบายนั้นกลับมาด้วย ผู้อ่านจึงรู้ว่าคำว่า "short" หมายถึงอะไรสำหรับธุรกิจนี้ แทนที่จะเดาจากชื่อ enum · ส่วนหัวข้อแบบข้อความอิสระรายสายอยู่ในไฟล์ผลวิเคราะห์และไม่มีคอลัมน์ให้จัดกลุ่ม จึงตั้งใจไม่เอามาอ้างตรงนี้
พารามิเตอร์: ชุดร่วม (รวม agentKey ที่ส่งซ้ำได้)
{
"period": { "…": "เหมือนด้านบน" },
"basis": "call_tag",
"total": 1284,
"items": [
{
"tag": "normal",
"description": "บทสนทนาปกติ มีเนื้อหา/ติดต่อธุรกิจจริง",
"calls": 742,
"share": 0.5779,
"answered": 742,
"missed": 0,
"talkTimeMs": 168420000,
"avgScore": 81.3,
"scored": 731,
"comparison": { "calls": 690, "changePct": 7.54, "share": 0.5798 }
}
]
}
- คืนหมวดครบชุดเสมอ —
normal,short,wrong_or_spam,no_answer,otherและnoneสำหรับสายที่ AI ยังไม่จัดหมวด — เรียงจากมากไปน้อย · หมวดที่มี 0 สายก็ยังปรากฏ ไม่หายไปเงียบ ๆ descriptionเป็นnullสำหรับnoneเพราะไม่ใช่คลาสที่ธุรกิจอธิบายไว้ แต่แปลว่า "ยังไม่ได้จัดหมวด"shareเป็นสัดส่วน 0-1 ของสายในช่วงนั้น ส่วนcomparison.shareคือสัดส่วนของ tag เดียวกันในช่วงก่อนหน้า เพราะสัดส่วนขยับได้แม้ปริมาณจะเท่าเดิม เมื่อสิ่งรอบข้างโตขึ้น
GET /insights/questions
ลูกค้าพูดถึงเรื่องอะไรบ้างในช่วงนั้น และสายเหล่านั้นทำได้ดีแค่ไหน ต่างจาก /insights/topics ตรงที่ตัวนั้นรายงาน tag ชุดตายตัว 5 หมวด ส่วนตัวนี้เป็น open-vocabulary คือค้นหาเองว่าลูกค้าพูดเรื่องอะไร โดยไม่ต้องมีใครประกาศหัวข้อไว้ล่วงหน้า
นับเฉพาะคำพูดของลูกค้าเท่านั้น เพราะเทเลพูดสคริปต์เดิมทุกสาย ถ้านับทั้งสองฝั่ง คำทักทายจะชนะทุกหัวข้อจริง — วัดจากข้อมูลจริงบน production วลีที่พบบ่อยที่สุด 3 อันดับแรกของธุรกิจหนึ่งใน 1 เดือนคือ สวัสดีค่ะ, ครับ, ฮัลโหล ซึ่งเป็นฝั่งเทเลทั้งหมด
การจัดอันดับใช้ log-odds ratio พร้อม informative Dirichlet prior (Monroe, Colaresi & Quinn 2008) เทียบกับคลังข้อความรวมทั้งแพลตฟอร์ม — ไม่ใช่ความถี่ดิบ (ซึ่งจะได้แต่คำลงท้าย) และไม่ใช่ TF-IDF (ซึ่งให้น้ำหนักคำหายากมากเกินไป) ค่า z-score ส่งกลับมาในชื่อ distinctiveness
วลีที่เขียนต่างกันเล็กน้อยจะถูกรวมด้วยความคล้าย pg_trgm ซึ่งรวมได้เฉพาะรูปเขียน/คำลงท้ายที่ต่างกัน ไม่ใช่คำพ้องความหมาย: กี่บาท ไม่มี trigram ร่วมกับ ราคาเท่าไหร่ เลย ค่าความคล้ายเป็น 0 พอดี การตั้งชื่อหัวข้อจากคลัสเตอร์เหล่านี้เป็นหน้าที่ของผู้เรียก — head, phrases, examples ถูกจำกัดขนาดให้เรียก LLM ครั้งเดียวจบ โดยที่โมเดลไม่เคยเห็นบทสนทนาเลย
| Param | Type | Note |
|---|---|---|
minCalls | integer 2-100 ค่าเริ่มต้น 3 | วลีต้องปรากฏในกี่สายจึงจะเป็นตัวเลือก |
limit | integer 1-150 ค่าเริ่มต้น 60 | จำนวนคลัสเตอร์ที่คืน เรียงจากพบบ่อยสุด |
pool | integer 50-600 ค่าเริ่มต้น 300 | จำนวนวลีคะแนนสูงสุดที่เข้าสู่การจัดกลุ่ม |
similarity | number 0.1-0.95 ค่าเริ่มต้น 0.45 | เกณฑ์ความคล้ายที่ถือว่าเป็นวลีเดียวกัน |
{
"period": { "…": "เหมือนด้านบน" },
"basis": "customer_utterances",
"background": { "basis": "platform_excluding_self", "corpusTokens": 878319, "refreshedAt": "2026-08-15T02:00:00.000Z" },
"statistic": { "name": "log_odds_ratio_informative_dirichlet_prior", "reference": "Monroe, Colaresi & Quinn (2008)", "similarityThreshold": 0.45, "minCalls": 3 },
"sentiment": { "basis": "score_banded", "thresholds": { "positive": 70, "neutral": 40 } },
"calls": 5169,
"totalCalls": 4820,
"items": [
{
"clusterId": 6,
"head": "ไม่ได้ทานแล้ว",
"phrases": [
{ "phrase": "ไม่ได้ทานแล้ว", "calls": 207, "occurrences": 241, "z": 19.3 },
{ "phrase": "ไม่ได้ทาน", "calls": 88, "occurrences": 92, "z": 11.1 }
],
"examples": ["อ๋อไม่ได้ทานแล้วค่ะ", "ยังไม่ได้ทานเลยค่ะ เก็บไว้ก่อน"],
"volume": { "calls": 207, "occurrences": 241, "share": 0.0429 },
"distinctiveness": 19.3,
"quality": {
"avgScore": 61.4, "scored": 198, "answered": 207, "missed": 0, "avgDurationMs": 142000,
"sentiment": { "positive": 62, "neutral": 104, "negative": 32, "unscored": 9 }
},
"comparison": { "calls": 164, "changePct": 26.22 },
"worstCalls": [{ "callId": "019ff109-…", "callAt": "2026-08-12T04:11:02.000Z", "score": 21 }]
}
]
}
volume.shareหารด้วยtotalCallsคือสายที่มีเสียงลูกค้าจริง ไม่ใช่ทุกสายในช่วงนั้น เพราะในช่วงเวลาหนึ่งมีทั้งสายที่ไม่มีคนรับ สายผิด และสายที่ยังวิเคราะห์ไม่เสร็จ ถ้าหารด้วยสายทั้งหมดจะทำให้ทุกหัวข้อดูเล็กกว่าความจริง และยิ่งเพี้ยนมากในธุรกิจที่มีสายหลุดเยอะbackground.basisปกติเป็นplatform_excluding_selfหรือเป็นplatform_pooledเมื่อข้อมูลของธุรกิจอื่นน้อยเกินไปจนเทียบไม่ได้ (ติดตั้งแบบผู้เช่ารายเดียว) ค่านี้ถูกรายงานออกมา ไม่ใช่ให้เดาเองsentimentคือคะแนน 0-100 ของ AI ที่แบ่งช่วงตามจุดตัดเดียวกับแดชบอร์ด เพราะอารมณ์รายสายอยู่ในเอกสารวิเคราะห์ ไม่มีคอลัมน์ให้ group by — เงื่อนไขเดียวกับ/insights/overviewworstCallsคืน id เท่านั้น ให้ไปดึงสายจาก/recordings/{id}— คำว่า "เราตอบเรื่องนี้ได้ไม่ดี" จะมีประโยชน์ก็ต่อเมื่อกดฟังสายนั้นได้- คำตอบถูก cache ตาม (ธุรกิจ, ช่วงเวลา, สิทธิ์ของผู้เรียก): 1 ชั่วโมงถ้าช่วงเวลายังไม่ปิด และ 7 วันเมื่อช่วงเวลาปิดแล้ว ดูได้จาก
x-cache: hit|miss - ถ้าช่วงเวลากว้างเกินกว่าจะคำนวณทันใน statement timeout จะได้ 400
period_too_largeให้แคบช่วงลง การลองซ้ำด้วยช่วงเดิมไม่ช่วย
GET /insights/quality
สายในช่วงนั้นทำได้ดีแค่ไหน: การกระจายคะแนน สัดส่วนสายที่ผ่านแต่ละขั้นของการรับสาย ปัญหาไหนพบบ่อยสุด และตัวชี้ไปยังสายที่ดีที่สุด/แย่ที่สุด
| Param | Type | Note |
|---|---|---|
scoreThreshold | int 0-100 | default 70 — เกณฑ์ว่าเท่าไรถือว่าผ่าน |
sampleSize | int 1-20 | default 5 — จำนวนตัวชี้ต่อฝั่ง (response บอกเพดานไว้ที่ maxSamples) |
บวกกับพารามิเตอร์ร่วม (รวม agentKey ที่ส่งซ้ำได้)
{
"period": { "…": "เหมือนด้านบน" },
"scoreThreshold": 70,
"quality": { "avgScore": 78.4, "scored": 1096, "passed": 612, "passRate": 0.5584 },
"scoreDistribution": [
{ "bucket": "0-19", "calls": 12, "share": 0.0093 },
{ "bucket": "20-39", "calls": 84, "share": 0.0654 },
{ "bucket": "40-59", "calls": 190, "share": 0.148 },
{ "bucket": "60-79", "calls": 398, "share": 0.31 },
{ "bucket": "80-100", "calls": 412, "share": 0.3208 },
{ "bucket": "unscored", "calls": 188, "share": 0.1464 }
],
"funnel": {
"total": 1284, "answered": 1102, "real": 968, "quality": 612,
"lostMissed": 182, "lostJunk": 134, "lostLowScore": 356, "scored": 968
},
"issues": [
{ "key": "no_answer", "calls": 182, "share": 0.1417 },
{ "key": "low_score", "calls": 484, "share": 0.3769 }
],
"maxSamples": 20,
"samples": {
"best": [{ "id": "0192b9...", "score": 98, "callAt": "2026-08-14T03:22:11.000Z" }],
"worst": [{ "id": "0192c1...", "score": 11, "callAt": "2026-08-09T07:41:02.000Z" }]
},
"comparison": {
"avgScore": { "previous": 76.1, "changePct": 3.02 },
"passRate": { "previous": 0.53, "changePct": 5.36 },
"scored": { "previous": 1004, "changePct": 9.16 }
}
}
scoreDistributionคืน 6 ช่วงเดิมเรียงลำดับนี้เสมอ กราฟจึงไม่ต้องรับมือกับช่วงที่หายไป- 4 ขั้นของ funnel เป็นเซตย่อยของขั้นบนอย่างเคร่งครัด ช่องว่างระหว่าง 2 ขั้นคือจำนวนสายที่หายไปเพราะสาเหตุนั้น:
total→answered(หักสายไม่รับ) →real(หักshort/wrong_or_spam) →quality(คะแนนถึงเกณฑ์) ·funnel.scoredบอกว่าในกลุ่มrealนั้น AI ให้คะแนนไปแล้วเท่าไร — ถ้าตัวเลขนี้ต่ำแปลว่ากลุ่มตัวอย่างของขั้นคุณภาพยังน้อย ไม่ได้แปลว่าทุกคนสอบตก issuesคือ 7 สัญญาณที่เป็นอิสระต่อกัน (no_answer,short,wrong_or_spam,other,low_score,unscored,processing_failed) เรียงจากมากไปน้อย · ไม่ใช่การแบ่งกลุ่มแบบไม่ทับซ้อน — สายเดียวเป็นได้ทั้งshortและunscored— ผลรวมจึงไม่เท่ากับtotalpassRateหารด้วยจำนวนสายที่มีคะแนน ไม่ใช่ทุกสาย เพราะสายที่ยังไม่ถูกให้คะแนนไม่ใช่สายที่สอบตก และการหารด้วยยอดรวมจะกดอัตราลงทุกครั้งที่คิววิเคราะห์ทำงานไม่ทันsamplesคืนแค่ id, คะแนน และเวลาโทร · ถ้าต้องการ transcript ให้ไปดึงต่อที่GET /recordings/detail
