Recordings

บันทึกการสนทนา + transcript + summary

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:0008: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

ParamTypeNote
limitint 1-200default 20
offsetintdefault 0
direction"in" | "out"optional
sourceProviderthreecx | dtac | upload | miniooptional
callDateFromวันที่หรือ ISO 8601optional — ดู วันที่และเวลา
callDateToวันที่หรือ ISO 8601optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น)
tzIANA timezonedefault Asia/Bangkok — ใช้เฉพาะตอน callDateFrom/callDateTo ไม่ได้ระบุ timezone มาเอง
customerPhonestring (exact match)optional
customerPhoneNormalizedstring (exact match)optional
tagnormal | short | wrong_or_spam | no_answer | other | nonenone = ยังไม่ติด tag
scoreMinint 0-100เฉพาะ row ที่มี score
scoreMaxint 0-100เฉพาะ row ที่มี score
searchstringilike ครอบ dataId, customerPhone, customerPhoneNormalized, callcenterNumber, callcenterName
sortBycallAt | duration | scoredefault callAt
sortDirasc | descdefault desc; null อยู่ท้ายสุด, tiebreak callAt desc
agentKeystring, ส่งซ้ำได้ (สูงสุด 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

ParamTypeNote
dateFromวันที่หรือ ISO 8601optional — ดู วันที่และเวลา
dateToวันที่หรือ ISO 8601optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น)
customerPhoneNormalizedstring (exact match)optional
tzIANA timezonedefault Asia/Bangkok — ใช้แบ่ง bucket วัน/ชั่วโมง และใช้อ่านค่าวันที่ที่ไม่ได้ระบุ timezone มาเอง
agentKeystring, ส่งซ้ำได้ (สูงสุด 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

ParamTypeNote
dataIdstring uniquerequired
callAtวันที่หรือ ISO 8601required — ดู วันที่และเวลา; วันล้วนเช่น 2026-05-20 = 2026-05-20T00:00:00 ตาม tz
tzIANA timezoneoptional, default Asia/Bangkok — บอกว่า callAt ที่ไม่มี offset เป็นเวลาเขตไหน (ถ้า callAt มี offset อยู่แล้วช่องนี้ไม่มีผล)
direction"in" | "out"required
customerPhonestringoptional
callcenterNumberstringoptional
callcenterNamestringoptional
tagenumoptional
scorenumber 0-100optional
notestringoptional
contentTypestring (MIME)default audio/wav
audioPresignedUploadUrlbooleanขอ 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

ParamTypeNote
scorenumber 0-100optional
tagnormal | short | wrong_or_spam | no_answer | otheroptional
reviewCommentstringoptional
notestringoptional
linkedUserIduuid | nullrequires 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)

ParamTypeNote
filefile (multipart)required — ไฟล์เสียงทุกฟอร์แมต (wav/mp3/m4a/mp4/aac/ogg/opus/webm/flac/amr/wma ฯลฯ), ≤500MB — ไฟล์ใหญ่/ไม่บีบอัดจะถูกบีบเป็น Opus ตอนจัดเก็บโดยไม่เสียคุณภาพ
direction"in" | "out"optional, default "in"
customerPhonestringoptional
callcenterNamestringoptional
callcenterNumberstringoptional
notestringoptional
callAtวันที่หรือ ISO 8601optional, default = เวลาปัจจุบัน; ไม่ระบุ timezone = อ่านตาม tzค่าที่ตีความได้ส่งกลับมาในคีย์ callAt ของ response จะได้ตรวจได้ว่าตรงกับที่ตั้งใจ
tzIANA timezoneoptional, 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
failedprovider ที่ล้มเหลว (เช่น ["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

fieldtypenotes
customNamestring (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

fieldtypenotes
businessNamestring (≤200)ชื่อที่ AI ใช้ — เว้นว่าง → fallback เป็นชื่อ business
businessAboutstring (≤4000)ธุรกิจทำเกี่ยวกับอะไร (บริบทให้ AI)
businessProductsstring (≤4000)สินค้า/บริการที่พูดถึงในสาย
tagDescriptionsobject, required5 คีย์: normal, short, wrong_or_spam, no_answer, other (แต่ละค่า ≤1000) — คีย์ที่ไม่ส่งมาหรือส่งค่าว่างจะถูกเติมด้วยคำอธิบายเริ่มต้นให้อัตโนมัติ และ response ของ GET จะคืนครบทั้ง 5 คีย์เสมอ
scoreCategoriesarray 1–20, requiredแต่ละหมวด { label (1–200), maxPoints (1–100 จำนวนเต็ม) }
summaryStyleshort | standard | detailedความยาวของ summary — สั้น = 1–2 ประโยค, มาตรฐาน = 2–3 ประโยค (ค่าเดิมของระบบ), ละเอียด = 4–6 ประโยค · ไม่ส่งมาหรือส่งค่าที่ไม่รู้จัก = standard
coachingStyleshort | standard | detailedความยาวของ recommendations — สั้น = 2–3 ข้อ (~400–800 ตัวอักษร), มาตรฐาน = 4–6 ข้อ (~1,000–2,000 ตัวอักษร, ค่าเดิมของระบบ), ละเอียด = 6–8 ข้อ (~2,000–3,500 ตัวอักษร) · ปรับแค่ความยาว กติกาการอ้างหลักฐานและการชี้จุดในสายเหมือนกันทุกแบบ
summaryInstructionsstring (≤2000)คำสั่งของธุรกิจสำหรับส่วนสรุป — ต่อท้ายกฎกลาง ไม่ได้แทนที่ · ส่งค่าว่างเพื่อล้าง
coachingInstructionsstring (≤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

ParamTypeNote
iduuidrequired
includeรายการคั่นด้วยจุลภาคจาก transcript, summary, coaching, sentimentdefault summary — เลือกทีละส่วนเพราะ transcript ก้อนใหญ่ที่สุด
maxTurnsint 1-2000default 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 นับครบทุก turn
  • emotionCounts / 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 รับชุดนี้:

ParamTypeNote
dateFromวันที่หรือ ISO 8601optional — ดู วันที่และเวลา
dateToวันที่หรือ ISO 8601optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น)
tzIANA timezonedefault Asia/Bangkok — เป็นเขตเวลาที่ใช้ตัด bucket วัน/ชั่วโมงด้วย
agentKeystring, ส่งซ้ำได้ (สูงสุด 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 → ชื่อที่ถูกประทับไว้บนสาย

ParamTypeNote
searchstringค้นได้ทั้ง extension, ชื่อที่ประทับบนสาย, ชื่อในไดเรกทอรีของ provider และชื่อที่ตั้งทับ
sortBycalls | answered | missed | talkTime | avgScore | answerRate | namedefault calls
sortDirasc | descdefault desc; null อยู่ท้ายสุด, ค่าเสมอกันตัดสินด้วย key ของ agent
limitint 1-200default 50 (response บอกเพดานไว้ที่ maxLimit)
offsetint ≥ 0default 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: false
  • comparison แยกรายแถวและเป็น null เมื่อดูแบบทั้งหมดตั้งแต่ต้น · คำนวณเฉพาะ agent ที่อยู่ในหน้านี้เท่านั้น

GET /insights/timeseries

หนึ่ง metric แบ่งตามช่วงเวลา พร้อมค่าสรุปของ metric เดียวกันในช่วงก่อนหน้า เพื่อให้กราฟมีอะไรให้เทียบ · bucket ถูกตัดตาม tz ไม่ใช่ UTC — ฮิสโทแกรมรายชั่วโมงของคอลเซ็นเตอร์ในกรุงเทพฯ ถ้าตัดตามเส้น UTC จะทำให้ช่วงเร่งด่วนตอนเช้าไปตกผิด bucket

ParamTypeNote
metriccalls | duration | sentiment | processeddefault calls
buckethour | day | week | monthdefault 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: hour2026-08-01T09:00, day2026-08-01, week → วันจันทร์ของสัปดาห์นั้น, month2026-08
  • จุดเรียงจากเก่าไปใหม่ สูงสุด 500 จุด (maxPoints) · ถ้าเกิน ระบบตัดปลายเก่าสุดทิ้งและตั้ง truncated เป็น true — ให้ย่อช่วงหรือขยาย bucket แทนที่จะอ่านกราฟที่ถูกตัดว่าเป็นคำตอบทั้งหมด
  • summary.current / summary.previous คือตัวเลขเดียวที่สรุป metric นั้นทั้งช่วง: จำนวนสาย, เวลาคุยรวมเป็นมิลลิวินาที, คะแนนเฉลี่ย หรือสัดส่วน 0-1
  • sentimentBasis จะเป็น { 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 ครั้งเดียวจบ โดยที่โมเดลไม่เคยเห็นบทสนทนาเลย

ParamTypeNote
minCallsinteger 2-100 ค่าเริ่มต้น 3วลีต้องปรากฏในกี่สายจึงจะเป็นตัวเลือก
limitinteger 1-150 ค่าเริ่มต้น 60จำนวนคลัสเตอร์ที่คืน เรียงจากพบบ่อยสุด
poolinteger 50-600 ค่าเริ่มต้น 300จำนวนวลีคะแนนสูงสุดที่เข้าสู่การจัดกลุ่ม
similaritynumber 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/overview
  • worstCalls คืน id เท่านั้น ให้ไปดึงสายจาก /recordings/{id} — คำว่า "เราตอบเรื่องนี้ได้ไม่ดี" จะมีประโยชน์ก็ต่อเมื่อกดฟังสายนั้นได้
  • คำตอบถูก cache ตาม (ธุรกิจ, ช่วงเวลา, สิทธิ์ของผู้เรียก): 1 ชั่วโมงถ้าช่วงเวลายังไม่ปิด และ 7 วันเมื่อช่วงเวลาปิดแล้ว ดูได้จาก x-cache: hit|miss
  • ถ้าช่วงเวลากว้างเกินกว่าจะคำนวณทันใน statement timeout จะได้ 400 period_too_large ให้แคบช่วงลง การลองซ้ำด้วยช่วงเดิมไม่ช่วย

GET /insights/quality

สายในช่วงนั้นทำได้ดีแค่ไหน: การกระจายคะแนน สัดส่วนสายที่ผ่านแต่ละขั้นของการรับสาย ปัญหาไหนพบบ่อยสุด และตัวชี้ไปยังสายที่ดีที่สุด/แย่ที่สุด

ParamTypeNote
scoreThresholdint 0-100default 70 — เกณฑ์ว่าเท่าไรถือว่าผ่าน
sampleSizeint 1-20default 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 ขั้นคือจำนวนสายที่หายไปเพราะสาเหตุนั้น: totalanswered (หักสายไม่รับ) → 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 — ผลรวมจึงไม่เท่ากับ total
  • passRate หารด้วยจำนวนสายที่มีคะแนน ไม่ใช่ทุกสาย เพราะสายที่ยังไม่ถูกให้คะแนนไม่ใช่สายที่สอบตก และการหารด้วยยอดรวมจะกดอัตราลงทุกครั้งที่คิววิเคราะห์ทำงานไม่ทัน
  • samples คืนแค่ id, คะแนน และเวลาโทร · ถ้าต้องการ transcript ให้ไปดึงต่อที่ GET /recordings/detail
เอกสาร API