Recordings

สายพร้อมบทถอดเสียงและบทสรุป

Recordings

หน่วยข้อมูลหลักของระบบ — สายที่บันทึกไว้หนึ่งสาย พร้อมบทถอดเสียงและบทสรุป

Account key: ทุก request ด้านล่างต้องส่ง X-Business-Id: <business id> เพื่อเลือกธุรกิจที่จะทำงานด้วย (ถ้าไม่ส่งจะได้ 400 business_required) ส่วน business key จะไม่อ่าน header นี้ เพราะผูกกับธุรกิจเดียวอยู่แล้ว ดูเพิ่มที่ เริ่มต้นใช้งาน → API key ระดับบัญชี

วันที่และเวลา

ทุกพารามิเตอร์ที่เป็นวัน/เวลาในหน้านี้รับ 3 รูปแบบ กติกาเดียวคือ — ถ้าค่านั้นบอก timezone มาเอง ระบบใช้ตามนั้น ถ้าไม่บอก ระบบอ่านตาม tz (ค่าเริ่มต้น 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

ดูรายการสายแบบแบ่งหน้า

แพ็กเกจ: ถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก รายการยังตอบตามปกติ แต่ช่องผลการวิเคราะห์ (tag score คะแนนลูกค้า transcriptUrl summaryUrl) เป็น null และ flags เป็น [] ส่วนการกรองหรือเรียงด้วยช่องเหล่านี้ (tag scoreMin/scoreMax ตัวกรองคะแนนลูกค้า flag flagLevel และ sortBy ที่เป็นคะแนน) จะได้ 403 feature_not_in_package ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ

Scopes: view_recording_all · view_recording_self

ParamTypeNote
limitint 1-200default 20
offsetintdefault 0
direction"in" | "out"optional
sourceProviderthreecx | dtac | upload | minio | scriptoptional — script = สายจากตัวดึงเสียงที่เพิ่มนอกเหนือจากชนิดในตัว
sourceSlugslug ของตัวดึงเสียงoptional — เฉพาะสายที่ดึงเข้ามาด้วยตัวดึงเสียงนี้ (ค่า sourceSlug ของแต่ละแถว ดูรายชื่อ slug ได้จาก GET /providers/catalog) · สายที่เชื่อมมาจากธุรกิจอื่นจะไม่ถูกกรองด้วยค่านี้ และ sourceSlug ของสายเหล่านั้นเป็น null เสมอ
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 = ยังไม่ติดแท็ก
scoreMinint 0-100เฉพาะสายที่มีคะแนนเทเล
scoreMaxint 0-100เฉพาะสายที่มีคะแนนเทเล
customerScoreMinint 0-100ขอบล่างของ customerScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนลูกค้า ไม่แยกประเภท — ลูกค้าที่สนใจซื้อมากที่สุด = customerScoreKind=sales&customerScoreMin=70 · ถ้าไม่ใส่ customerScoreKind จะได้ทั้ง 2 ประเภท (การขายและงานบริการ)
customerScoreMaxint 0-100ขอบบนของ customerScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนลูกค้า
customerScoreKindsales | service | noneเฉพาะสายที่คะแนนลูกค้าเป็นประเภทนี้ — sales = ความพร้อมซื้อ · service = ความพึงพอใจหลังโทรเรื่องบริการ/บิล · ใช้คู่กับ customerScoreMin/customerScoreMax ได้ช่วงคะแนนของประเภทเดียว · none = สายที่ยังไม่มีคะแนนลูกค้า (customerScore: null) ใช้คู่กับ customerScoreMin/customerScoreMax (หรือขอบเขต 4 ตัวด้านล่าง) ไม่ได้ (ได้ 400 validation_failed)
customerReadinessMinint 0-100ขอบล่างของ customerReadinessScore (ความพร้อมซื้อ, รวมค่านั้น) ทุกประเภทสาย เฉพาะสายที่มีคะแนนฝั่งนี้ · ถ้ามากกว่า customerReadinessMax จะได้ 400 validation_failed
customerReadinessMaxint 0-100ขอบบนของ customerReadinessScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนฝั่งนี้
customerSatisfactionMinint 0-100ขอบล่างของ customerSatisfactionScore (ความพึงพอใจ, รวมค่านั้น) ทุกประเภทสาย เฉพาะสายที่มีคะแนนฝั่งนี้ · ถ้ามากกว่า customerSatisfactionMax จะได้ 400 validation_failed
customerSatisfactionMaxint 0-100ขอบบนของ customerSatisfactionScore (รวมค่านั้น) เฉพาะสายที่มีคะแนนฝั่งนี้ — ใช้คู่กับขอบเขตความพร้อมซื้อได้ (ต้องตรงทั้งสองอย่าง)
searchstringค้นคำบางส่วน (ไม่สนตัวพิมพ์เล็กใหญ่) ใน dataId, customerPhone, customerPhoneNormalized, callcenterNumber, callcenterName และคำตอบข้อมูลลูกค้าที่เป็นข้อความ (เช่น ชื่อลูกค้าที่ AI ได้จากสาย) — ระบบไม่ค้นคำตอบของสายที่เชื่อมมาจากธุรกิจอื่น
sortBycallAt | duration | score | customerScore | customerReadinessScore | customerSatisfactionScoredefault callAt
sortDirasc | descdefault desc · ค่า null อยู่ท้ายสุด ถ้าเท่ากันจะเรียงตาม callAt desc แล้วตามด้วย id ของสาย
agentKeystring, ส่งซ้ำได้ (สูงสุด 500)เอาเฉพาะสายของพนักงานเหล่านี้ — เทียบกับ key ของพนักงาน (ตัวเดียวกับที่ /recordings/agents คืน) คือ callcenterNumber ของสาย ถ้าสายไม่มีเบอร์จึงใช้ callcenterName เช่น ?agentKey=%2B66804972699&agentKey=Somchai
flagstring, ส่งซ้ำได้ (สูงสุด 100)เอาเฉพาะสายที่มี Flag ใด Flag หนึ่งในนี้ ระบุด้วย key ของ Flag เช่น ?flag=churn_risk&flag=customer_complaint — key ทั้งหมดดูได้จาก GET /recordings/insight-config · key ที่รูปแบบผิดได้ 400 validation_failed ส่วน key ที่ธุรกิจไม่ได้ตั้งไว้จะไม่เจอสายไหนเลย
flagLevelcritical | warning | positive | info, ส่งซ้ำได้เอาเฉพาะสายที่มี Flag ระดับนี้อย่างน้อยหนึ่ง Flag — ?flagLevel=critical&flagLevel=warning คือสายที่ควรดู

ถ้าส่งค่าซ้ำในพารามิเตอร์เดียว ตรงค่าใดค่าหนึ่งก็พอ · ถ้าส่งหลายพารามิเตอร์พร้อมกัน ต้องตรงทุกพารามิเตอร์ ตัวกรอง flag และ flagLevel นับเฉพาะ Flag ชุดเดียวกับที่แสดงใน flags ของแต่ละแถว (ดู flags — Flag ของสาย ด้านล่าง) ทุกแถวที่ได้กลับมาจึงมี Flag ที่ขอไว้ให้เห็นจริง

ทุกการเรียงลำดับปิดท้ายด้วย id ของสาย (ทิศเดียวกับ sortDir) เป็นตัวตัดสินสุดท้าย สายที่เวลาหรือคะแนนเท่ากันจึงเรียงแบบเดิมทุกหน้า — แบ่งหน้าด้วย offset แล้วไม่มีสายหล่นหายหรือซ้ำ · มีผลเฉพาะลำดับของสายที่เสมอกันเป๊ะเท่านั้น

ถ้ารายการยาวเกินกว่าจะใส่ใน URL — เบอร์หลายพันเบอร์ในรูปแบบไหนก็ได้ หรือ id ของสายถึง 1,000 ตัว — ใช้ POST /recordings/search ซึ่งรับตัวกรองชุดเดียวกันใน JSON body

curl "https://phone.mcloud.co.th/api/v1/recordings?limit=10&direction=in&sortBy=score&sortDir=desc" \
  -H "Authorization: Bearer crk_..."

สายวันนี้ที่ควรดู:

curl "https://phone.mcloud.co.th/api/v1/recordings?callDateFrom=2026-10-11&callDateTo=2026-10-11&flagLevel=critical&flagLevel=warning" \
  -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,
      "customerScore": 72,
      "customerScoreKind": "sales",
      "customerReadinessScore": 72,
      "customerSatisfactionScore": 81,
      "durationMs": 184320,
      "processingTimeMs": 35210,
      "processingStatus": "completed",
      "audioUrl": "/api/recording/audio/0192b9...",
      "transcriptUrl": "/api/recording/transcript/0192b9...",
      "summaryUrl": "/api/recording/summary/0192b9...",
      "flags": [
        { "key": "customer_complaint", "label": "ลูกค้าร้องเรียน", "severity": "critical" }
      ],
      "cost": { "chargedThb": 2.4132, "currency": "THB" }
    }
  ],
  "total": 1284,
  "limit": 10,
  "offset": 0
}

flags — Flag ของสาย

ทุกแถวของ GET /recordings, GET /recordings/{id}, GET /recordings/detail และรายการสายในคอลเลกชันมี flags เสมอ — Flag ที่ติดอยู่กับสายนั้น เรียงระดับเร่งด่วนขึ้นก่อน (critical → warning → positive → info) แล้วตามลำดับที่ธุรกิจจัดไว้ ถ้าไม่มี Flag จะได้ []

"flags": [
  { "key": "customer_complaint", "label": "ลูกค้าร้องเรียน", "severity": "critical" },
  { "key": "callback_promised", "label": "รับปากโทรกลับ", "severity": "info" }
]
  • key ไม่มีวันเปลี่ยน ใช้เก็บและเทียบในระบบของคุณได้เลย ธุรกิจแก้ชื่อ Flag เมื่อไหร่ key ก็ยังเหมือนเดิม · ส่วน label กับ severity อ่านจากการตั้งค่าล่าสุดของธุรกิจทุกครั้ง
  • ส่งมาเฉพาะ Flag ที่เชื่อถือได้ คือ Flag ที่คนเพิ่มเอง หรือ Flag ที่ AI ติดพร้อมประโยคที่หาเจอในสายจริง Flag ที่ AI หาประโยคยืนยันไม่เจอจะไม่ออกทาง API และ Flag ที่หัวหน้ากดว่าไม่ถูกต้องก็จะหายไปจากที่นี่ด้วย
  • [] แปลว่าตอนนี้สายนี้ไม่มี Flag ซึ่งรวมถึงสายที่ยังไม่ได้ตรวจ เช่น สายก่อนธุรกิจเปิดใช้ หรือสายที่การตรวจยังไม่เสร็จ
  • สายที่เชื่อมมาจากธุรกิจอื่น (linkedFrom ไม่เป็น null) ได้ flags: [] เสมอ เพราะ Flag เป็นของธุรกิจต้นทาง
  • ประโยคหลักฐานของแต่ละ Flag อยู่ที่ GET /recordings/detail?include=flags · ชื่อและระดับของทุก key อยู่ที่ GET /recordings/insight-config

customerScore — คะแนนลูกค้า

ทุกแถวของ GET /recordings, GET /recordings/{id} และ GET /recordings/detail มีคะแนน 2 ตัว:

  • score — คะแนนเทเล ดูว่าพนักงานดูแลสายได้ดีแค่ไหน (เหมือนเดิม)
  • customerScore — คะแนนลูกค้า 0-100 · ระบบดูก่อนว่าสายนี้เป็นสายแบบไหน แล้วบอกไว้ใน customerScoreKind:
    • sales — ลูกค้าสนใจ/พร้อมซื้อแค่ไหน ประเมินตามหมวดการขายที่ธุรกิจตั้งเอง (ชุดตั้งต้น: ความต้องการชัดเจน · ความพร้อมด้านงบประมาณ · ช่วงเวลาตัดสินใจ · การตอบรับขั้นตอนถัดไป)
    • service — สายหลังการขาย/แจ้งปัญหา/เรื่องบิล: ลูกค้าพึงพอใจแค่ไหน ประเมินตามหมวดงานบริการของธุรกิจ (ชุดตั้งต้น: ปัญหา/คำถามได้รับการแก้ไข · ความพึงพอใจต่อการบริการ · ความมั่นใจต่อบริการ · ความรวดเร็วและง่าย)

ลูกค้าที่พร้อมซื้อที่สุด และลูกค้าที่พอใจน้อยที่สุด:

curl "https://phone.mcloud.co.th/api/v1/recordings?customerScoreKind=sales&customerScoreMin=70&sortBy=customerScore&sortDir=desc" \
  -H "Authorization: Bearer crk_..."
curl "https://phone.mcloud.co.th/api/v1/recordings?customerScoreKind=service&customerScoreMax=39&sortBy=customerScore&sortDir=asc" \
  -H "Authorization: Bearer crk_..."
  • null แปลว่าสายนี้ไม่มีคะแนนลูกค้า: ไม่มีคนรับสาย (tag: "no_answer") ยังไม่ได้ประเมิน (สายก่อนธุรกิจเริ่มใช้จะไม่ถูกประเมินย้อนหลังเอง) หรือประเมินไม่สำเร็จ · การประเมินทำต่อจากการวิเคราะห์ทันที สายจึงอาจขึ้น processingStatus: "completed" ก่อน customerScore มาถึงไม่กี่วินาที · customerScoreKind เป็น null ก็ต่อเมื่อ customerScore เป็น null
  • สายที่ยังไม่มีคะแนนลูกค้า: GET /recordings?customerScoreKind=none — ถ้าใส่ customerScoreMin/customerScoreMax มาด้วยจะได้ 400 validation_failed
  • คะแนนที่ให้ไว้ก่อนมีการแยกประเภทจะได้ customerScoreKind: "sales" เพราะตอนนั้นทุกสายประเมินตามหมวดการขาย
  • คะแนนลูกค้ารายหมวดพร้อมเหตุผล และเหตุผลที่ระบบดูว่าสายนี้เป็น sales หรือ service อยู่ที่ GET /recordings/detail — summary.customerScoreBreakdown กับ summary.customerScoreKindReason · คะแนนเทเลรายหมวดพร้อมเหตุผลก็อยู่ที่นั่นเช่นกัน — summary.scoreBreakdown
  • สายที่เชื่อมมาจากธุรกิจอื่นมีคะแนนลูกค้าและประเภทติดมาด้วยเหมือน score

customerReadinessScore / customerSatisfactionScore — ทุกสายมีคะแนนทั้ง 2 ฝั่ง

ทุกสายที่ประเมินแล้วจะได้คะแนนทั้ง 2 ชุดหมวดในรอบเดียวกัน จึงมีตัวเลข 2 ตัวข้าง customerScore:

  • customerReadinessScore — ความพร้อมซื้อ 0-100 ตามหมวดการขาย
  • customerSatisfactionScore — ความพึงพอใจ 0-100 ตามหมวดงานบริการ

customerScore ยังเป็นคะแนนหลักของทั้งสองตัว — คือฝั่งที่ customerScoreKind บอก — สายขาย (sales) จึงมี customerReadinessScore เท่ากับ customerScore และสายบริการ (service) มี customerSatisfactionScore เท่ากับ customerScore · อีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัดในฝั่งนั้นเลย (เช่น สายแจ้งปัญหาที่ไม่มีเรื่องซื้อขาย) ให้แสดงเป็น "—" อย่าแสดงเป็น 0 · ทั้งสองตัวเป็น null ก็ต่อเมื่อ customerScore เป็น null · สายที่ประเมินก่อนมีคะแนน 2 ฝั่ง จะมี customerScore อยู่ในฝั่งตามประเภทของสาย ส่วนอีกฝั่งเป็น null

ลูกค้าที่พร้อมซื้อแต่ไม่พอใจ — ทุกประเภทสาย:

curl "https://phone.mcloud.co.th/api/v1/recordings?customerReadinessMin=70&customerSatisfactionMax=39&sortBy=customerReadinessScore&sortDir=desc" \
  -H "Authorization: Bearer crk_..."
  • customerReadinessMin/Max และ customerSatisfactionMin/Max เอาเฉพาะสายที่มีคะแนนฝั่งนั้น · ถ้าขอบล่างมากกว่าขอบบนจะได้ 400 validation_failed · sortBy=customerReadinessScore / customerSatisfactionScore เอาสายที่ไม่มีคะแนนฝั่งนั้นไว้ท้ายเสมอทั้งสองทิศ
  • คะแนนรายหมวดของแต่ละฝั่งอยู่ที่ GET /recordings/detail — summary.customerReadinessBreakdown กับ summary.customerSatisfactionBreakdown

processingTimeMs — เวลาที่ใช้ถอดเสียง

ทุกแถวของ GET /recordings, GET /recordings/{id}, GET /recordings/detail และรายการสายในคอลเลกชันมี processingTimeMs — เวลาที่ AI ใช้กับสายนั้น หน่วยมิลลิวินาที (ถอดเสียงและวิเคราะห์รวมกัน) ส่วน durationMs คือความยาวของสาย

  • นับเฉพาะเวลาที่ AI ทำงาน ไม่รวมเวลารอคิว ก่อนเริ่มถอด
  • อ่านจากการวิเคราะห์ครั้งล่าสุดที่สำเร็จ — สายที่สั่งวิเคราะห์ใหม่จะได้เวลาของผลล่าสุด
  • null แปลว่ายังไม่มีการวิเคราะห์ที่สำเร็จ: ยังไม่ได้ถอด กำลังถอดเป็นครั้งแรก หรือถอดไม่สำเร็จทุกครั้ง
  • สายที่เชื่อมมาจากธุรกิจอื่นมีค่านี้ติดมาด้วยเหมือน score

cost — เครดิตที่สายนี้ใช้ไป

ทุกแถวของ GET /recordings และ GET /recordings/{id} มี cost.chargedThb — ยอดบาทสุทธิที่ถูกหักจากกระเป๋าสำหรับสายนั้น (ทุกยอดที่หักเพราะสายนั้นรวมกัน หักด้วยการคืนเงินถ้ามี — แพ็กเกจที่คิดตามการใช้ AI หักทีละครั้งที่เรียก AI ส่วนแพ็กเกจคิดเป็นนาทีหักครั้งเดียวต่อการวิเคราะห์หนึ่งรอบ ตามความยาวเสียง) ตัวเลขนี้อ่านจากบัญชีเครดิตโดยตรง ไม่ได้คำนวณใหม่ จึงตรงกับยอดที่ถูกหักจริงเสมอ

ถ้าธุรกิจอยู่ใต้พาร์ทเนอร์ กระเป๋าที่ถูกหักคือกระเป๋ารวมของพาร์ทเนอร์ ใช้ตัวเลขนี้ตั้งบิลต่อให้ลูกค้าของคุณเองได้เลย

0 ไม่ได้แปลว่าฟรี — แปลว่า "ยังไม่ถูกหัก" ได้ด้วย เช่นตอน AI ยังไม่ได้รัน (ในแพ็กเกจคิดเป็นนาที สายที่จ่ายด้วยเครดิตทดลองทั้งหมดหรือสั้นกว่า 10 วินาทีก็เป็น 0 เช่นกัน) ให้อ่านคู่กับ processingStatus เสมอ แล้วนับเป็นยอดเรียกเก็บเมื่อ processingStatus เป็น completed แล้วเท่านั้น

POST /recordings/search

GET /recordings ที่ส่งตัวกรองมาใน JSON body — สำหรับรายการที่ใส่ใน URL ไม่ได้ เช่น สายของเบอร์ลูกค้า 10,000 เบอร์จาก CRM ของคุณ หรือ id ของสาย 1,000 ตัว · ตัวกรองทุกตัวของ GET /recordings อยู่ที่นี่ด้วยชื่อเดิมและความหมายเดิม พร้อมตัวกรองแบบรายการเพิ่มเข้ามา · เป็นการอ่าน ที่ใช้ POST เพียงเพราะขนาดของคำขอ

แพ็กเกจ: เหมือน GET /recordings ถ้าแพ็กเกจไม่รวมการส่งผลออก ช่องผลการวิเคราะห์ (tag score คะแนนลูกค้า transcriptUrl summaryUrl) เป็น null และ flags เป็น [] ส่วนตัวกรองหรือ sortBy ที่ใช้ช่องเหล่านี้ (tag/tags ช่วงคะแนน customerScoreKind flag flagLevel) จะได้ 403 feature_not_in_package

Scopes: view_recording_all · view_recording_self

body ใช้กติกาของ Phones → Body ของคำขอแบบ batch และจับคู่เบอร์ด้วย phone key — ส่งรูปแบบไหนก็ได้ (Phones → วิธีจับคู่เบอร์โทร)

ฟิลด์TypeNote
filtersobjectoptional — ตัวกรองด้านล่าง · ไม่ส่งหรือส่ง null = ไม่กรอง
sortByเหมือน GET /recordingsdefault callAt
sortDirasc | descdefault desc
limitint 1-200default 50
offsetint ≥ 0default 0
cursorstringoptional — nextCursor ของหน้าก่อน · ใช้ได้เฉพาะ sortBy=callAt และใช้คู่กับ offset ที่มากกว่า 0 ไม่ได้
includeTotalbooleandefault true — false = ข้ามการนับ แล้วตอบ total: null

filters รับตัวกรองค่าเดี่ยวทุกตัวของ GET /recordings ด้วยชื่อเดิมและกติกาเดิม — direction, sourceProvider, sourceSlug, callDateFrom, callDateTo, tz, customerPhone, customerPhoneNormalized, tag, scoreMin, scoreMax, customerScoreMin, customerScoreMax, customerScoreKind, customerReadinessMin, customerReadinessMax, customerSatisfactionMin, customerSatisfactionMax และ search — พร้อมตัวกรองแบบรายการเหล่านี้:

ตัวกรองTypeNote
customerPhonesstring[] ไม่เกิน 10,000 ค่า (แต่ละค่าไม่เกิน 64 ตัวอักษร)สายของเบอร์ใดเบอร์หนึ่งในนี้ จับคู่ด้วย phone key
idsuuid[] ไม่เกิน 1,000 ค่าสายเหล่านี้
tagsarray ของ normal | short | wrong_or_spam | no_answer | other | noneสายที่มีแท็กใดแท็กหนึ่งในนี้ · none = ยังไม่ติดแท็ก · ใช้คู่กับ tag ไม่ได้ (400)
processingStatusarray ของ pending | transcribing | completed | failed | insufficient_creditสายที่อยู่ในสถานะใดสถานะหนึ่งนี้
reviewGradestring[] (แต่ละค่า 1-30 ตัวอักษร)สายที่มีเกรดใดเกรดหนึ่งในนี้ — reviewGrade ที่ระบบของคุณเขียนไว้ด้วย PATCH /recordings/{id} เทียบตรงตัว · none = ไม่มีเกรด · เกรดที่ระบุชื่อจะตรงเฉพาะสายของธุรกิจนี้เอง ส่วน none ตรงกับสายที่เชื่อมมาจากธุรกิจอื่นทุกสายด้วย เพราะ reviewGrade ของสายเหล่านั้นเป็น null เสมอ
agentKeystring[] ไม่เกิน 500 ค่าagentKey แบบส่งซ้ำของ GET
callcenterNamesstring[] ไม่เกิน 500 ค่า (แต่ละค่า 1-200 ตัวอักษร)สายที่ชื่อพนักงาน — callcenterName ที่บันทึกไว้กับสาย — ตรงกับชื่อใดชื่อหนึ่งในนี้ทุกตัวอักษร: ตัวพิมพ์เล็ก/ใหญ่ต้องตรง ไม่ตัดช่องว่าง และไม่จับคู่บางส่วน · ต่างจาก agentKey (หนึ่ง key ต่อหนึ่งเบอร์ภายใน) ตรงที่แยกพนักงานสองคนที่ใช้เบอร์ภายในเดียวกันออกจากกันได้ · ค่าที่ใช้ได้ดูจาก GET /recordings/callcenter-names ซึ่งอธิบายกรณีสายที่เชื่อมมาจากธุรกิจอื่นไว้ด้วย
flagstring[] ไม่เกิน 100 ค่าflag แบบส่งซ้ำของ GET
flagLevelarray ของ critical | warning | positive | infoflagLevel แบบส่งซ้ำของ GET

ค่าในรายการเดียวกัน ตรงค่าใดค่าหนึ่งก็พอ (OR) · ส่วนตัวกรองต่างตัวต้องตรงทุกตัว (AND) — ส่ง agentKey คู่กับ callcenterNames คือ "พนักงานคนนี้ และชื่อนี้" · ไม่ส่ง = ไม่กรอง, [] = ไม่เจอสายไหนเลย, null = 400 · เพดานของ customerPhones, ids และ callcenterNames นับตามจำนวนที่ส่งมา ตัวอื่นนับเฉพาะค่าที่ไม่ซ้ำ · กติกาข้ามฟิลด์ของ GET ยังใช้อยู่ โดย issue จะชี้ไปที่ filters. (เช่น filters.customerScoreKind)

curl -X POST https://phone.mcloud.co.th/api/v1/recordings/search \
  -H "Authorization: Bearer crk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "customerPhones": ["0812345678", "+66 89-876-5432"],
      "callDateFrom": "2026-10-01",
      "tags": ["normal", "none"]
    },
    "limit": 100,
    "includeTotal": false
  }'
{
  "items": [
    { "id": "0192c1d4-7a20-7b61-9e3f-2d81c5a04b17", "…": "แถวเดียวกับ GET /recordings รวม cost" }
  ],
  "total": null,
  "limit": 100,
  "offset": 0,
  "nextCursor": "eyJjYWxsQXQiOiIyMDI2LTEwLTE0VDA4OjAyOjUxLjQxMjAwMFoiLCJpZCI6IjAxOTJjMWQ0LTdhMjAtN2I2MS05ZTNmLTJkODFjNWEwNGIxNyJ9"
}
  • items คือแถวแบบเดียวกับ GET /recordings ทุกประการ — รวมสายที่เชื่อมมาจากธุรกิจอื่นและซ่อนข้อมูลแบบเดียวกัน cost คิดจากกระเป๋าของธุรกิจนี้
  • แบ่งหน้าด้วย offset ใช้ได้กับทุก sortBy
  • แบ่งหน้าด้วย cursor ใช้ได้เฉพาะ sortBy=callAt: ส่ง nextCursor ของหน้านั้นกลับมาเป็น cursor พร้อม filters และ sortDir ชุดเดิม · แม่นยำแม้มีสายใหม่เข้ามาระหว่าง export ยาว ๆ ซึ่ง offset อาจทำให้สายหล่นหายหรือซ้ำ · หน้าที่มาด้วย cursor จะตอบ offset: null
  • nextCursor มีให้ทุกหน้าที่ sortBy=callAt และยังมีหน้าถัดไป — ไม่ว่าจะมาถึงหน้านั้นด้วย offset หรือ cursor จึงสลับมาใช้ cursor กลางทางได้ · หน้าสุดท้ายเป็น null และเป็น null เสมอเมื่อ sortBy เป็นค่าอื่น
  • ให้ส่ง cursor กลับมาตามที่ได้รับ อย่าแก้เอง · cursor ที่ endpoint นี้ไม่ได้ออกให้ได้ 400 invalid_cursor · ใช้ cursor กับ sortBy อื่น หรือคู่กับ offset ที่มากกว่า 0 ได้ 400 validation_failed ที่ชี้ไปที่ cursor

อ่านรายการทั้งหมดทีละหน้า:

let cursor = null
do {
  const res = await fetch('https://phone.mcloud.co.th/api/v1/recordings/search', {
    method: 'POST',
    headers: { Authorization: 'Bearer crk_...', 'Content-Type': 'application/json' },
    body: JSON.stringify({ filters, limit: 200, includeTotal: false, cursor }),
  })
  const page = await res.json()
  handle(page.items)
  cursor = page.nextCursor
} while (cursor)
codeHTTPเมื่อไหร่
invalid_payload422body ไม่ใช่ JSON object
validation_failed400คีย์ที่ไม่รู้จัก ค่าเกินขอบเขต ส่ง tag คู่กับ tags ส่ง null ให้ตัวกรองแบบรายการ ผิดกติกาข้ามฟิลด์ของ GET หรือใช้ cursor ในแบบที่ใช้ไม่ได้
invalid_cursor400cursor ไม่ได้ออกโดย endpoint นี้

GET /recordings/stats

สถิติรวมของสายตามช่วงเวลา — ยอดรวม จำนวนสายต่อวัน การกระจายคะแนน ชั่วโมงที่สายเข้าหนาแน่น และพนักงานที่มีสายมากที่สุด

แพ็กเกจ: ถ้าแพ็กเกจไม่รวมการส่งผลออก totals.avgScore topPerformers[].avgScore scoreDistribution และ tagDistribution เป็น null ส่วนตัวเลขจำนวนยังครบ ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ

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)เอาเฉพาะสายของพนักงานเหล่านี้ (ความหมายเดียวกับ endpoint ที่ดูรายการสาย)
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 (เฉพาะสายที่มีคะแนน)
  • tagDistribution: จำนวนสายต่อแท็กของผู้ตรวจ พร้อม untagged สำหรับสายที่ยังไม่ติดแท็ก ทุกคีย์มีเสมอ เลข 0 จึงแปลว่า "ไม่มี" ไม่ใช่ "ไม่ได้ส่งมา" — ใช้ตอบ "มีสายที่ไม่มีคนรับกี่สาย" ได้โดยไม่ต้องไล่ทั้งรายการ
  • peakHours: มีครบ 24 ชั่วโมง (0–23, เติม 0 ให้ชั่วโมงที่ไม่มีสาย)
  • topPerformers: พนักงาน 10 อันดับแรก (ตาม callcenter name) เรียงตามจำนวนสาย

POST /recordings/stats

สถิติชุดเดียวกัน แต่ส่งตัวกรองมาใน JSON body — สำหรับรายการเบอร์ที่ใส่ใน URL ไม่ได้ · response เหมือน GET /recordings/stats ทุกประการ · เป็นการอ่าน ที่ใช้ POST เพียงเพราะขนาดของคำขอ

แพ็กเกจ: เหมือน GET /recordings/stats และถ้าแพ็กเกจไม่รวมการส่งผลออก การส่ง tags จะได้ 403 feature_not_in_package เพราะจำนวนสายของแท็กหนึ่งบอกแท็กของสายได้

Scopes: view_recording_all · view_recording_self

body รับพารามิเตอร์ทุกตัวของ GET ด้วยชื่อเดิม — dateFrom, dateTo, tz, customerPhoneNormalized และ agentKey เป็น array — พร้อมตัวกรองสายอีก 4 ตัว:

ฟิลด์TypeNote
agentKeystring[] ไม่เกิน 500 ค่าagentKey แบบส่งซ้ำของ GET · เกิน 500 ได้ 400 (ส่วน GET ตัดส่วนเกินทิ้งเงียบ ๆ)
customerPhonesstring[] ไม่เกิน 10,000 ค่า (แต่ละค่าไม่เกิน 64 ตัวอักษร)เฉพาะสายของเบอร์ใดเบอร์หนึ่งในนี้ จับคู่ด้วย phone key — รูปแบบไหนก็ได้ (Phones → วิธีจับคู่เบอร์โทร)
tagsarray ของ normal | short | wrong_or_spam | no_answer | other | noneเฉพาะสายที่มีแท็กใดแท็กหนึ่งในนี้ · none = ยังไม่ติดแท็ก
callcenterNamesstring[] ไม่เกิน 500 ค่า (แต่ละค่า 1-200 ตัวอักษร)เฉพาะสายที่ชื่อพนักงานที่บันทึกไว้กับสายตรงกับชื่อใดชื่อหนึ่งในนี้ทุกตัวอักษร — แบบเดียวกับ POST /recordings/search
directionin | outเฉพาะสายเข้าหรือสายออกตามที่ระบุ
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/stats \
  -H "Authorization: Bearer crk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "dateFrom": "2026-10-01",
    "dateTo": "2026-10-31",
    "customerPhones": ["0812345678", "+66 89-876-5432"],
    "tags": ["normal"],
    "direction": "in"
  }'
  • ทุกตัวเลข รวมถึง topPerformers คำนวณจากสายที่ผ่านตัวกรองแล้ว
  • body ใช้กติกาของ Phones → Body ของคำขอแบบ batch: customerPhones: [], tags: [], callcenterNames: [] หรือ agentKey: [] ไม่ตรงกับสายไหนเลย ทุกตัวเลขจึงเป็นศูนย์ · ถ้าไม่ต้องการกรองให้ไม่ส่งคีย์นั้น · คีย์ที่ไม่รู้จักได้ 400 · null ในค่าเดี่ยว (dateFrom, tz, direction, …) ถือว่าไม่ได้ตั้ง

POST /recordings

ส่งข้อมูลสายเข้าระบบ พร้อมขอ presigned URL สำหรับอัปโหลดไฟล์ (ถ้าต้องการ) — ส่ง dataId เดิมซ้ำได้โดยไม่เกิดสายซ้ำ (idempotent)

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)optional, default audio/wav — ต้องเป็นชนิดไฟล์เสียง/วิดีโอ (ดูด้านล่าง)
audioPresignedUploadUrlbooleanขอ presigned URL สำหรับอัปโหลดไฟล์หรือไม่

contentType รับเฉพาะชนิดไฟล์เสียงและวิดีโอ: audio/mpeg (audio/mp3), audio/wav (audio/x-wav, audio/wave, audio/vnd.wave), audio/ogg, audio/opus, audio/webm, audio/mp4 (audio/m4a, audio/x-m4a), audio/aac, audio/flac, audio/amr, audio/3gpp, audio/aiff, audio/x-ms-wma, video/mp4, video/webm และชื่อเรียกอื่นอีกเล็กน้อย (รายการเต็มคือ enum ของ contentType ใน OpenAPI spec) เทียบแบบไม่สนตัวพิมพ์เล็ก/ใหญ่และดูแค่ชนิดหลัก จึงส่ง audio/webm; codecs=opus ได้ โดยระบบจะเก็บเป็นชนิดหลักล้วน (audio/webm) ถ้าไม่ส่งหรือส่งค่าว่างจะถือเป็น audio/wav ค่าอื่นทั้งหมด เช่น text/html, image/svg+xml, application/octet-stream จะได้ 422 validation_failed พร้อม issues ที่ชี้ไปที่ contentType (ข้อความ unsupported_content_type) และจะไม่มีการบันทึกอะไร

audioUrl จะส่งไฟล์เสียงกลับด้วยชนิดไฟล์นั้นเสมอ (หรือเป็นไฟล์ดาวน์โหลด สำหรับรายการเก่าที่ชนิดที่บันทึกไว้ไม่อยู่ในรายการ) พร้อม X-Content-Type-Options: nosniff — Content-Type ที่คุณส่งตอน PUT ไปที่ 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
  }'

อัปโหลดไฟล์เสียงแล้วเริ่มวิเคราะห์ — เรียก 3 ครั้ง:

  1. POST /recordings พร้อม audioPresignedUploadUrl: true → ได้ id ของสาย และ uploadUrl (ใช้ได้ 15 นาที)
  2. PUT ไฟล์เสียงไปที่ uploadUrl
  3. POST /recordings/{id}/reextract → ระบบหาไฟล์เสียงที่อัปโหลดไว้เจอ แล้วนำสายเข้าคิว (202) · ถ้ายังไม่ได้เรียกขั้นนี้ สายจะค้างอยู่ที่ processingStatus: "pending" และไม่ถูกวิเคราะห์ · ถ้าไฟล์ยังอัปโหลดไม่เสร็จ จะได้ 409 audio_not_uploaded และไม่มีอะไรเปลี่ยน — PUT ให้เสร็จแล้วเรียกใหม่

ธุรกิจที่เก็บไฟล์ไว้ในที่เก็บซึ่งเข้าจากอินเทอร์เน็ตไม่ได้ จะไม่ได้ presigned URL — ส่ง audioPresignedUploadUrl: true แล้วได้ 409 presigned_upload_unavailable และระบบไม่สร้างสาย ให้อัปโหลดไฟล์ผ่าน POST /recordings/analyze แทน

curl -X PUT "$UPLOAD_URL" --data-binary @call.wav
curl -X POST https://phone.mcloud.co.th/api/v1/recordings/$ID/reextract \
  -H "Authorization: Bearer crk_..."

GET /recordings/{id}

ดึงข้อมูลสายเดียว

แพ็กเกจ: ถ้าแพ็กเกจไม่รวมการส่งผลออก ยังดูสถานะ ความยาว และยอดเงินของสายได้ แต่ช่องผลการวิเคราะห์ (tag score คะแนนลูกค้า transcriptUrl summaryUrl) เป็น null และ flags เป็น []

Scopes: view_recording_all · view_recording_self

PATCH /recordings/{id}

ตรวจสาย — คะแนน / แท็ก / ความเห็น / โน้ต / เกรด / การผูกผู้ใช้

Scopes: manage_recording_review · manage_recording_link

ParamTypeNote
scorenumber 0-100optional
tagnormal | short | wrong_or_spam | no_answer | otheroptional
reviewCommentstringoptional
notestringoptional
reviewGradestring 1-30 | nulloptional — เกรดที่ระบบของคุณให้สายนี้ ใช้คำของคุณเอง (เช่น excellent, good, needs_improvement) เก็บตามที่ส่งมา (ตัดช่องว่างหัวท้าย) · null = ล้างเกรด และ reviewedByLabel · ระบบไม่รับ none (ไม่ว่าตัวพิมพ์ไหน) เพราะตัวกรองของ search อ่านค่านี้ว่า "ไม่มีเกรด"
reviewedByLabelstring 1-100 | nulloptional — ผู้ตรวจตามชื่อที่ระบบของคุณใช้ · null = ล้างค่า
linkedUserIduuid | nullต้องมี manage_recording_link
curl -X PATCH https://phone.mcloud.co.th/api/v1/recordings/$ID \
  -H "Authorization: Bearer crk_..." \
  -H "Content-Type: application/json" \
  -d '{"reviewGrade":"good","reviewedByLabel":"CRM admin: Nok"}'
  • ส่งเฉพาะสิ่งที่จะเปลี่ยน — ฟิลด์ที่ไม่ส่งจะคงค่าเดิม เช่น แก้แค่คอมเมนต์ เกรดและชื่อผู้ตรวจที่มีอยู่ก็ยังอยู่
  • ทุกฟิลด์ยกเว้น linkedUserId นับเป็นการตรวจและต้องมี manage_recording_review · การตรวจทุกครั้ง รวม reviewGrade และ reviewedByLabel จะทำเครื่องหมายว่าสายนี้ตรวจแล้วด้วย: reviewStatus เป็น reviewed, reviewedAt เป็นเวลาปัจจุบัน และบันทึกผู้ใช้เจ้าของ API key เป็นผู้ตรวจ
  • reviewStatus เขียนผ่าน API ไม่ได้ — เป็นสถานะของ REXcord ว่า "มีคนตรวจสายนี้แล้ว" ไม่ใช่เกรด · ถ้าส่งมาตัวเดียวจะได้ 422 empty_patch · ถ้าส่งคู่กับฟิลด์อื่น ระบบจะไม่สนใจค่านี้ · ผลการประเมินของคุณให้ใส่ใน reviewGrade
  • reviewGrade: null คู่กับ reviewedByLabel ที่เป็น string ได้ 422 validation_failed — การล้างเกรดจะล้างชื่อผู้ตรวจไปด้วย ชื่อที่ส่งมาจึงจะหายไป · ค่าผิดอื่น ๆ (เกรดยาวเกิน 30 ตัวอักษร, none) ก็ได้ 422 validation_failed เช่นกัน
  • REXcord ไม่ได้ใช้ reviewGrade หรือ reviewedByLabel ทำอะไร — เป็นค่าของคุณที่เขียนแล้วอ่านกลับได้ และกรองได้ด้วย reviewGrade ใน POST /recordings/search
  • response คือข้อมูลสายที่อัปเดตแล้ว ไม่มี cost · สายที่เชื่อมมาจากธุรกิจอื่นตอบ 404 เหมือนการเขียนทุกแบบ

ทุก endpoint ที่อ่านสาย — GET /recordings, POST /recordings/search, GET /recordings/{id}, GET /recordings/detail และรายการสายในคอลเลกชัน — มี reviewGrade และ reviewedByLabel ในทุกแถว: เป็น null เมื่อยังไม่มีใครเขียน และเป็น null เสมอบนสายที่เชื่อมมาจากธุรกิจอื่น (การตรวจเป็นของธุรกิจนั้น เหมือน reviewStatus และ note)

DELETE /recordings/{id}

ลบแบบ soft-delete (ย้ายไปถังขยะ เก็บไว้ 30 วัน)

Scopes: manage_call_recording

POST /recordings/{id}/reextract

สั่ง AI ถอดเสียงและสรุปสายนี้ ทำใหม่อีกรอบ หรือทำครั้งแรกหลัง POST /recordings และ PUT ไปที่ presigned URL (ดู อัปโหลดไฟล์เสียงแล้วเริ่มวิเคราะห์ ด้านบน) · คิดเครดิตเหมือนการวิเคราะห์ครั้งอื่น และ callback ของคุณจะทำงานเมื่อเสร็จหรือไม่สำเร็จ

Scopes: manage_call_recording

curl -X POST https://phone.mcloud.co.th/api/v1/recordings/$ID/reextract \
  -H "Authorization: Bearer crk_..."
{ "ok": true, "id": "0192c1d4-7a20-7b61-9e3f-2d81c5a04b17", "status": "queued" }
  • ไฟล์เสียงของสายต้องอยู่ในที่เก็บไฟล์แล้ว · สายที่สร้างด้วย POST /recordings จะยังไม่ถูกบันทึกว่ามีไฟล์เสียงจนกว่า endpoint นี้จะหาเจอ: ระบบจะหาไฟล์ที่อัปโหลดไว้ ถ้ามีไฟล์และไฟล์ไม่ว่าง จะบันทึกว่าสายนี้อัปโหลดแล้วและนำเข้าคิว
  • 202 = เข้าคิวแล้ว: สายถูกตั้งกลับเป็น pending โดยมีไฟล์เสียงพร้อม · ถ้าตอนนั้นติดต่อตัวประมวลผล AI ไม่ได้ สายก็ยังถูกวิเคราะห์ในรอบถัดไปของตัวประมวลผล · ติดตามผลได้จาก processingStatus ของ GET /recordings/{id} หรือจาก callback
  • สายที่เชื่อมมาจากธุรกิจอื่นตอบ 404 เหมือนการเขียนทุกแบบ
codeHTTPเมื่อไร
not_found404ไม่มีสายนี้ในธุรกิจนี้
audio_not_uploaded409ไม่มีไฟล์เสียงในที่เก็บไฟล์ — PUT ยังไม่เสร็จ ไม่สำเร็จ หรือส่งไปที่ URL ที่หมดอายุแล้ว — หรือไฟล์ว่าง · ไม่มีอะไรเปลี่ยน อัปโหลดแล้วเรียกใหม่
storage_unavailable503ถามที่เก็บไฟล์ไม่ได้ว่ามีไฟล์เสียงหรือยัง · ไม่มีอะไรเปลี่ยน ลองใหม่

POST /recordings/analyze

ส่งไฟล์เสียงมาวิเคราะห์แล้วรอรับผลในคำขอเดียว — ระบบเก็บไฟล์ ให้ AI วิเคราะห์ แล้วตอบผลทั้งหมดพร้อมยอดที่คิดเงินแล้ว ระบบจะเก็บไฟล์และแสดงในหน้าเว็บเหมือนสายอื่น ใช้เวลาหลายนาทีตามความยาวสาย ระบบรอผลให้นานสุด 6 ชั่วโมง จึงควรตั้ง client timeout ให้ยาว หรือไม่ตั้งเลย (ดู การรอผล ด้านล่าง)

แพ็กเกจ: ถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก endpoint นี้ตอบ 403 feature_not_in_package ก่อนอ่านไฟล์เสียง ให้ส่งสายด้วย POST /recordings แทน ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ

Scopes: manage_call_recording

body เป็น multipart/form-data (ไม่ใช่ JSON) ส่วน wait ส่งใน query string

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, ค่าเริ่มต้นคือเวลาปัจจุบัน · ถ้าไม่ระบุ timezone จะอ่านตาม tz — ระบบส่งค่าที่อ่านได้กลับมาในคีย์ callAt ของ response เพื่อให้ตรวจได้ว่าตรงกับที่ตั้งใจ
tzIANA timezoneoptional, default Asia/Bangkok — บอกว่า callAt ที่ไม่มี offset เป็นเวลาเขตไหน
waitinteger (query)optional, default 21600 (6 ชั่วโมง) — รอผลนานสุดกี่วินาที ใส่จำนวนเต็ม 1–21600 นับตั้งแต่ระบบเก็บไฟล์เสร็จ
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 "[email protected]" \
  -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,
    "scoreBreakdown": [
      { "label": "การทักทาย", "weight": 20, "pct": 100, "reason": "แนะนำตัวและบริษัทครบ" },
      { "label": "การนำเสนอ", "weight": 50, "pct": 84, "reason": "อธิบายโปรโมชั่นแต่ไม่ได้ถามงบของลูกค้า" },
      { "label": "การปิดการขาย", "weight": 30, "pct": 80, "reason": "นัดส่งใบเสนอราคาภายในวันนี้" }
    ],
    "customerScore": 72,
    "customerScoreKind": "sales",
    "customerScoreKindReason": "ลูกค้าโทรมาสอบถามโครงการเพื่อซื้อ",
    "customerScoreBreakdown": [
      { "label": "ความต้องการชัดเจน", "weight": 40, "pct": 85, "reason": "ถามราคาและวันเข้าชมโครงการ" },
      { "label": "งบประมาณ", "weight": 30, "pct": null, "reason": "ไม่ได้พูดถึงงบ" },
      { "label": "ความเร่งด่วน", "weight": 30, "pct": 55, "reason": "อยากได้ภายในปีนี้" }
    ],
    "customerReadinessScore": 72,
    "customerReadinessBreakdown": [
      { "label": "ความต้องการชัดเจน", "weight": 40, "pct": 85, "reason": "ถามราคาและวันเข้าชมโครงการ" },
      { "label": "งบประมาณ", "weight": 30, "pct": null, "reason": "ไม่ได้พูดถึงงบ" },
      { "label": "ความเร่งด่วน", "weight": 30, "pct": 55, "reason": "อยากได้ภายในปีนี้" }
    ],
    "customerSatisfactionScore": null,
    "customerSatisfactionBreakdown": [
      { "label": "ปัญหา/คำถามได้รับการแก้ไข", "weight": 35, "pct": null, "reason": "ไม่ได้แจ้งปัญหา" },
      { "label": "ความพึงพอใจต่อการบริการ", "weight": 30, "pct": null, "reason": "" },
      { "label": "ความมั่นใจต่อบริการ", "weight": 20, "pct": null, "reason": "" },
      { "label": "ความรวดเร็วและง่าย", "weight": 15, "pct": null, "reason": "" }
    ],
    "tag": "normal",
    "overallEmotion": "positive",
    "overallTone": "friendly",
    "insight": {
      "flags": [
        { "key": "price_concern", "label": "ลูกค้าติดเรื่องราคา", "severity": "info", "quote": "ราคานี้แพงไปหน่อยนะคะ", "reason": "ลูกค้าบอกว่าราคาสูงกว่างบ", "startMs": 51200 }
      ],
      "customerFields": [
        { "key": "interest", "label": "สิ่งที่สนใจ", "type": "text", "value": "โปรโมชั่นบ้านเดี่ยว", "quote": "สนใจโปรบ้านเดี่ยวค่ะ" },
        { "key": "budget", "label": "งบประมาณ", "type": "number", "value": 3500000, "quote": "งบประมาณสามล้านห้า" }
      ]
    }
  },
  "cost": { "chargedThb": 0.1234, "currency": "THB" },
  "durationMs": 73210
}

cost.chargedThb คือยอดที่คิดเงินกับธุรกิจ (ราคาสุทธิ) ระบบจะไม่เปิดเผยต้นทุนดิบ อัตรา หรือรุ่น AI

result.insight คือ Flag และข้อมูลลูกค้าที่ได้จากสายนี้ ตามรายการที่ธุรกิจตั้งไว้ (ดู GET /recordings/insight-config):

  • flags ใช้กติกาเดียวกับ flags ของสาย คือเฉพาะ Flag ที่เชื่อถือได้ เรียงระดับเร่งด่วนขึ้นก่อน · แต่ละ Flag มีหลักฐานติดมาด้วย: quote คำพูดในสายที่ Flag นั้นอ้างถึง (null ถ้าเป็น Flag ที่คนใส่เอง หรือตอนนั้นระบบอ่านหลักฐานไม่ได้ — ดูด้านล่าง) · reason เหตุผลหนึ่งประโยคว่าทำไมขึ้น Flag นี้ · startMs ตำแหน่งในไฟล์เสียงที่คำพูดนั้นเริ่ม (มิลลิวินาที) — หน้าตาเดียวกับ GET /recordings/detail?include=flags
  • customerFields มีเฉพาะคำถามที่ได้คำตอบ · type คือชนิดของคำถาม (text · number · date · choice · yes_no — เป็น null เฉพาะกรณีที่เกิดน้อยมาก คือระบบอ่านชนิดของคำถามไม่ได้ในตอนนั้น) · value เป็นชนิดตามคำถาม: ข้อความ ตัวเลข วันที่ YYYY-MM-DD หรือ boolean · quote คำพูดในสายที่ได้คำตอบนั้นมา (null ถ้าไม่มี) — หน้าตาเดียวกับ GET /recordings/detail?include=customerFields ต่างกันแค่ไม่มีคำถามที่ไม่ได้คำตอบ
  • ถ้าธุรกิจปิดการตรวจ Flag ไว้ สายสั้นเกินไป หรือการตรวจใช้เวลานานจนต้องไปทำต่อเบื้องหลัง ทั้งสองรายการจะว่าง — ค่าล่าสุดดูทีหลังได้จาก GET /recordings/{recordingId}
  • เป็น null เฉพาะตอนที่ผลวิเคราะห์ไม่มีส่วนนี้มาเลย

result.scoreBreakdown คือคะแนนเทเลรายหมวด (score แยกตามหมวด) — รายการเดียวกับที่แท็บ "คะแนน" ในหน้าสายแสดง และเดียวกับ summary.scoreBreakdown ของ GET /recordings/detail · หมวดคือหมวดคะแนนเทเลของธุรกิจ (scoreCategories ของ GET/PUT /recordings/prompt-config): label ชื่อหมวด · weight น้ำหนักของหมวดเป็นเปอร์เซ็นต์ · pct ที่สายนี้ได้ในหมวดนั้น เป็นจำนวนเต็ม 0-100 (ไม่เป็น null) · reason เหตุผลหนึ่งบรรทัดว่าทำไมได้เท่านั้น — รอบประเมินคะแนนลูกค้าที่ทำต่อจากการวิเคราะห์เป็นผู้เขียน จึงเป็น null ถ้ารอบนั้นปิดอยู่ ยังไม่เสร็จทันเวลา หรือเหตุผลไม่ตรงกับหมวดแล้ว และเหตุผลไม่เปลี่ยน score · scoreBreakdown เป็น null ถ้าผลวิเคราะห์ไม่ได้เก็บคะแนนรายหมวดไว้

result.customerScore / result.customerScoreKind คือคะแนนลูกค้าของสายนี้และประเภทของคะแนน — ค่าเดียวกับที่ GET /recordings คืนมา (ดู customerScore ด้านบน) · result.customerScoreKindReason คือเหตุผลหนึ่งบรรทัดที่ระบบดูว่าสายนี้เป็น sales หรือ service ค่าเดียวกับ summary.customerScoreKindReason ของ GET /recordings/detail (null ถ้าไม่มีเหตุผลให้) ส่วน result.customerScoreBreakdown คือคะแนนรายหมวด รายการเดียวกับ summary.customerScoreBreakdown ของ GET /recordings/detail · ระบบอ่านคะแนนทันทีหลังรอบประเมินคะแนนลูกค้าที่ทำต่อจากการวิเคราะห์ (สำหรับ endpoint นี้ รอบนั้นมีเวลาจำกัดสั้น ๆ) ทั้งสี่ช่องเป็น null ถ้าตอนนั้นสายยังไม่มีคะแนนลูกค้า — ไม่มีคนรับสาย ธุรกิจปิดการประเมินคะแนนลูกค้าไว้ ประเมินไม่สำเร็จ หรือใช้เวลานานจนต้องไปทำต่อเบื้องหลัง — ค่าล่าสุดดูทีหลังได้จาก GET /recordings/{recordingId}

result.customerReadinessScore / result.customerSatisfactionScore คือคะแนนลูกค้าทั้ง 2 ฝั่ง (ดู customerReadinessScore / customerSatisfactionScore ด้านบน) พร้อมรายหมวด result.customerReadinessBreakdown / result.customerSatisfactionBreakdown — รายการเดียวกับ summary.customerReadinessBreakdown / summary.customerSatisfactionBreakdown ของ GET /recordings/detail · ฝั่งที่ customerScoreKind บอกเท่ากับ customerScore ส่วนอีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัด (รายหมวดยังอาจมีครบทุกหมวดแต่ pct: null แบบตัวอย่างด้านบน) · ทั้งสี่ช่องเป็น null เมื่อ customerScore เป็น null

scoreBreakdown · customerScoreKindReason · quote / reason / startMs ของ Flag และ type / quote ของข้อมูลลูกค้า ระบบอ่านก่อนส่งผลไม่นาน และจะไม่ทำให้ผลมาช้าเกิน wait ถ้าอ่านไม่ทัน (ฐานข้อมูลช้าหรือขัดข้องชั่วคราว) ช่องเหล่านี้จะเป็น null ส่วนอื่นยังส่งมาครบตามปกติ — null ในช่องเหล่านี้จึงแปลได้อีกอย่างว่า "ตอนนั้นอ่านไม่ได้" · ถ้าต้องการค่าที่แน่นอน ให้อ่านสายนี้ซ้ำจาก GET /recordings/detail?id=<recordingId>&include=summary,flags,customerFields

การรอผล (?wait=)

พอระบบเก็บไฟล์เสร็จ ระบบจะเริ่มตอบทันทีทั้งที่ยังไม่มีผล:

  1. ส่ง status 200 พร้อม header X-Recording-Id, X-Data-Id และ Content-Type: application/json
  2. ระหว่างวิเคราะห์ ส่ง ช่องว่าง 1 ตัวทุก 15 วินาที ให้การเชื่อมต่อไม่หลุด
  3. วิเคราะห์เสร็จ (หรือครบเวลา wait) แล้วจึงส่ง JSON ตามมา

ช่องว่างหน้า JSON ถูกต้องตามมาตรฐาน JSON.parse, res.json(), requests.json() และ Postman จึงอ่านได้ตามปกติ

ถ้าไม่ใส่ wait ระบบจะรอนานสุด 6 ชั่วโมง ถ้าอยากให้เลิกรอเร็วกว่านั้น ใส่ ?wait=<วินาที> (1–21600) เช่น รอแค่ 10 นาที:

curl -X POST "https://phone.mcloud.co.th/api/v1/recordings/analyze?wait=600" \
  -H "Authorization: Bearer crk_..." \
  -F "[email protected]"
  • wait เริ่มนับหลังระบบเก็บไฟล์เสร็จ ให้ตั้ง timeout ฝั่ง client ยาวกว่า wait เล็กน้อย (เผื่อเวลาอัปโหลด) หรือไม่ตั้งเลย — ใน Postman ไปที่ Settings → General → Request timeout แล้วใส่ 0 · ครบเวลา wait แล้วระบบตอบ extraction_timeout ให้เอง
  • ถ้าการเชื่อมต่อหลุดกลางทาง (เน็ตหลุด หรือระบบของเราอัปเดตหลายรอบระหว่างที่รอนานมาก) การวิเคราะห์ยังทำต่อจนเสร็จ ใช้ X-Recording-Id ที่ได้ไปแล้วดึงผลด้วย GET /recordings/{id} ไม่ต้องส่งไฟล์ซ้ำ (ถ้าส่งซ้ำ ระบบจะวิเคราะห์และคิดเงินอีกรอบ)

ข้อผิดพลาด

ข้อผิดพลาดมี 2 แบบ ขึ้นกับว่าเกิดก่อนหรือหลังระบบเก็บไฟล์เสร็จ

ก่อนเก็บไฟล์ — ตอบเป็น HTTP status จริง ระบบยังไม่ได้บันทึกอะไร แก้แล้วส่งใหม่ได้เลย

CodeStatusความหมาย
missing_token · invalid_token · token_disabled401ปัญหาที่ตัว API key
insufficient_scope403API key ไม่มี scope manage_call_recording
business_required400account key ที่ไม่ได้ส่ง X-Business-Id
forbidden_business403X-Business-Id ผิดรูปแบบ หรือเจ้าของ key ไม่ได้เป็นสมาชิกของธุรกิจนั้น
expected_form_data415body ไม่ใช่ multipart/form-data
audio_file_too_large413ไฟล์ใหญ่เกิน 500MB
audio_unsupported_format415ไม่ใช่ไฟล์เสียงที่รองรับ
audio_file_required422ไม่ได้แนบ file
invalid_call_at422อ่านค่า callAt ไม่ได้
invalid_wait422wait ไม่ใช่จำนวนเต็ม 1–21600
worker_unavailable503ระบบวิเคราะห์ยังไม่พร้อม ลองใหม่ภายหลัง
storage_unavailable503บันทึกไฟล์ไม่สำเร็จในตอนนี้ ระบบไม่ได้เก็บอะไรไว้ ลองใหม่ภายหลัง

หลังเก็บไฟล์ — ระบบส่ง status 200 ไปแล้ว ข้อผิดพลาดจึงมาใน body พร้อม recordingId / dataId ของสายที่บันทึกไว้ ส่วน error.status คือ HTTP status ที่ตรงกับข้อผิดพลาดนั้น:

{ "error": { "code": "extraction_timeout", "status": 504 }, "recordingId": "0190f7b2-...", "dataId": "api-2f1c..." }
error.codeerror.statusความหมาย
insufficient_credit402เครดิตของธุรกิจไม่พอสำหรับการวิเคราะห์
extraction_failed502วิเคราะห์ไม่สำเร็จ
extraction_timeout504ครบเวลา wait แล้วยังไม่เสร็จ — การวิเคราะห์ยังทำต่อจนเสร็จ ดึงผลทีหลังด้วย GET /recordings/{recordingId}

จึงต้องเช็กคีย์ error ใน body ทุกครั้ง ไม่ใช่ดูแค่ HTTP status:

const res = await fetch(url, { method: 'POST', headers, body: form })
const body = await res.json()
if (!res.ok || body.error) {
  // body.error.code บอกสาเหตุ · body.recordingId (ถ้ามี) คือสายที่บันทึกไว้แล้ว
}

GET /recordings/agents

รายชื่อพนักงานรับสาย — รวมพนักงานที่พบในสาย (มี calls / lastCallAt จริง) เข้ากับ พนักงานที่ซิงก์มาจาก provider แต่ยังไม่มีสาย (จะได้ calls: 0 กับ lastCallAt: null) พนักงานที่เพิ่งดึงจาก provider จึงขึ้นในรายการได้ก่อนมีสายแรก นำ key ที่ได้ไปใช้เป็นตัวกรอง agentKey กับ endpoint รายการสายและสถิติได้เลย (key คือ callcenterNumber ของพนักงาน ถ้าไม่มีเบอร์จึงใช้ callcenterName)

Scopes: view_recording_all · view_recording_self (API key ที่มีแค่ view_recording_self เห็นเฉพาะพนักงานที่ผูกกับผู้ใช้เจ้าของ key)

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
}

GET /recordings/callcenter-names

ชื่อพนักงานทุกชื่อที่บันทึกไว้กับสายที่คุณอ่านได้ — คือค่าที่ตัวกรอง callcenterNames รับ (ใน POST /recordings/search และ /recordings/stats, /insights/overview|agents|timeseries แบบ POST) · GET /recordings/agents เก็บชื่อเดียวต่อหนึ่ง key พนักงานสองคนที่เคยใช้เบอร์ภายในเดียวกันจึงกลายเป็นพนักงานคนเดียวที่นั่น ส่วนที่นี่เป็นสองชื่อ แต่ละชื่อมีจำนวนสายของตัวเอง

Scopes: view_recording_all · view_recording_self (API key ที่มีแค่ view_recording_self เห็นเฉพาะชื่อบนสายของตัวเอง)

ParamTypeNote
dateFromวันที่หรือ ISO 8601optional — เฉพาะสายตั้งแต่เวลานี้ (callAt) ดู วันที่และเวลา
dateToวันที่หรือ ISO 8601optional — วันล้วน = ถึงสิ้นวันนั้น (รวมวันนั้นด้วย)
tzIANA timezonedefault Asia/Bangkok — ใช้อ่านวันที่ที่ไม่ได้ระบุเขตเวลา

ถ้าไม่ส่งทั้ง dateFrom และ dateTo จะนับทุกสายที่มี

curl "https://phone.mcloud.co.th/api/v1/recordings/callcenter-names?dateFrom=2026-10-01&dateTo=2026-10-31" \
  -H "Authorization: Bearer crk_..."
{
  "items": [
    { "name": "Aom", "calls": 37, "lastCallAt": "2026-10-15T09:12:40.000Z" },
    { "name": "Bee", "calls": 112, "lastCallAt": "2026-10-16T03:41:07.000Z" }
  ],
  "total": 2
}
  • เรียงตาม name · สายที่ไม่มีชื่อหรือชื่อว่างไม่นับ · ส่ง name กลับไปตามที่ได้ทุกตัวอักษร — ตัวกรองเทียบตรงตัว (ตัวพิมพ์เล็ก/ใหญ่ต้องตรง ไม่ตัดช่องว่าง)
  • calls เท่ากับจำนวนสายที่ callcenterNames: [name] คืนในช่วงเวลาเดียวกันพอดี และ lastCallAt คือสายล่าสุดในนั้น
  • สายที่เชื่อมมาจากธุรกิจอื่น มีชื่อที่ธุรกิจนั้นบันทึกไว้ ชื่อจึงทำงานแบบเดียวกับ agentKey แบบธรรมดา: ชื่อที่ธุรกิจนี้เคยบันทึกไว้กับสายของตัวเองแม้แต่สายเดียว หมายถึงพนักงานของธุรกิจนี้ — นับ และตัวกรองจับคู่ เฉพาะสายของธุรกิจนี้เอง ไม่รวมคนชื่อซ้ำของธุรกิจที่เชื่อมมา · ชื่อที่มีอยู่แค่บนสายที่เชื่อมมา จะนับและจับคู่สายที่เชื่อมมาเหล่านั้น

POST /recordings/agents/sync

ดึงรายชื่อพนักงานทั้งหมดจากทุกการเชื่อมต่อของธุรกิจที่ตัวดึงเสียงมีรายชื่อ (รายชื่อเบอร์ภายในของ 3CX / รายชื่อผู้ใช้ของ DTAC / ตัวดึงเสียงใดก็ได้ที่ GET /providers/catalog บอก features.people) เข้ารายชื่อพนักงานของระบบ — รวมการเชื่อมต่อที่ปิดรอบดึงไว้ด้วย หลังจากนี้ GET /recordings/agents จะคืนพนักงานครบทุกคน รวมถึงคนที่ยังไม่มีสาย ทำงานเหมือนปุ่ม "ดึงจาก provider" ในหน้าทีมของแอป เป็นการ เรียกสดไปที่ระบบปลายทาง (login + list) จึงอาจใช้เวลาสองสามวินาที

Scopes: manage_recording_link

Fieldความหมาย
syncedจำนวนพนักงานที่เพิ่มหรืออัปเดตในรายชื่อ
providersจำนวนการเชื่อมต่อที่ถูกขอรายชื่อ — 0 = ไม่มีการเชื่อมต่อที่มีรายชื่อ
failedslug ของตัวดึงเสียงที่การเชื่อมต่อตอบไม่สำเร็จ (เช่น ["dtac"]) — ไม่สำเร็จแค่บางส่วน ส่วนที่เหลือยังทำงานตามปกติ
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) ของพนักงาน — เหมือนการแก้ชื่อบนการ์ดในหน้าทีม โดย {extension} คือ number ของพนักงานที่ได้จาก endpoint รายชื่อพนักงาน (ต้อง URL-encode เช่น +66804972699 → %2B66804972699) ชื่อใหม่จะมีผลกับทุกสายที่มี callcenterNumber เดียวกัน รวมถึงสายเก่า ทำให้ leaderboard รายการสาย และส่วนหัวของ player แสดงชื่อใหม่ทันที

ส่ง customName: null เพื่อล้างชื่อที่ตั้งเอง แล้วกลับไปใช้ชื่อจาก provider

Scope: manage_recording_link

fieldtypenotes
customNamestring (1–128) | null, requiredชื่อใหม่ — ส่ง null เพื่อล้างชื่อที่ตั้งเอง
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 เมื่อไม่พบเบอร์ภายในนี้ในธุรกิจ

GET /recordings/prompt-config

อ่าน config การวิเคราะห์สายด้วย AI ของธุรกิจ — บริบทธุรกิจที่ AI ใช้เป็น persona คำอธิบายแท็กทั้ง 5 ประเภท เกณฑ์การให้คะแนนแบบถ่วงน้ำหนัก ความยาวของผลลัพธ์ (summaryStyle / coachingStyle กับคำสั่งเพิ่มเติมของธุรกิจ) เกณฑ์สายสั้น (shortCallMaxSec) และหมวดคะแนนลูกค้า (customerScore — มีเฉพาะตอนที่เปิดใช้คะแนนลูกค้าอยู่) ซึ่งเป็น config เดียวกับที่หน้า /app/prompt ในแอปใช้จัดการ และ AI จะนำไปประกอบเป็น persona ทุกครั้งที่ถอดเสียงหรือวิเคราะห์สาย · ธุรกิจที่ยังไม่เคยตั้งค่าจะได้ค่าเริ่มต้น (คำอธิบายแท็ก เกณฑ์รวม 100 คะแนน และความยาว standard) โดย businessName จะใช้ชื่อธุรกิจแทน

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": "",
  "shortCallMaxSec": null,
  "shortCallMaxSecDefault": 30,
  "shortCallMaxSecEffective": 30,
  "customerScore": {
    "categories": [
      { "label": "ความต้องการชัดเจน", "maxPoints": 40 },
      { "label": "งบประมาณ", "maxPoints": 30 },
      { "label": "นัดขั้นตอนถัดไป", "maxPoints": 30 }
    ],
    "serviceCategories": [
      { "label": "ปัญหา/คำถามได้รับการแก้ไข", "maxPoints": 35, "hint": "ปัญหาหรือคำถามของลูกค้าได้คำตอบหรือทางแก้ที่ชัดเจนในสายนี้" },
      { "label": "ความพึงพอใจต่อการบริการ", "maxPoints": 30, "hint": "ลูกค้าพูดหรือแสดงออกว่าพอใจ ขอบคุณ ไม่บ่นหรือหงุดหงิด" },
      { "label": "ความมั่นใจต่อบริการ", "maxPoints": 20, "hint": "ลูกค้ามั่นใจว่าจะได้รับการดูแลต่อ ไม่พูดถึงการยกเลิกหรือย้ายไปที่อื่น" },
      { "label": "ความรวดเร็วและง่าย", "maxPoints": 15, "hint": "ได้สิ่งที่ต้องการโดยไม่ต้องรอนาน ไม่ถูกโอนหลายต่อ ไม่ต้องติดต่อซ้ำ" }
    ],
    "isDefault": false,
    "isServiceDefault": true,
    "defaultCategories": [
      { "label": "ความต้องการชัดเจน", "maxPoints": 30, "hint": "ลูกค้าบอกได้ว่าอยากได้อะไร แบบไหน ใช้ทำอะไร" },
      { "label": "ความพร้อมด้านงบประมาณ", "maxPoints": 25, "hint": "พูดถึงงบ ราคาที่รับได้ วิธีจ่าย" },
      { "label": "ช่วงเวลาตัดสินใจ", "maxPoints": 20, "hint": "จะซื้อ/ตัดสินใจเมื่อไร มีกำหนดชัดไหม" },
      { "label": "การตอบรับขั้นตอนถัดไป", "maxPoints": 25, "hint": "รับนัด ขอใบเสนอราคา ส่งเอกสาร ให้ช่องทางติดต่อ" }
    ],
    "defaultServiceCategories": [
      { "label": "ปัญหา/คำถามได้รับการแก้ไข", "maxPoints": 35, "hint": "ปัญหาหรือคำถามของลูกค้าได้คำตอบหรือทางแก้ที่ชัดเจนในสายนี้" },
      { "label": "ความพึงพอใจต่อการบริการ", "maxPoints": 30, "hint": "ลูกค้าพูดหรือแสดงออกว่าพอใจ ขอบคุณ ไม่บ่นหรือหงุดหงิด" },
      { "label": "ความมั่นใจต่อบริการ", "maxPoints": 20, "hint": "ลูกค้ามั่นใจว่าจะได้รับการดูแลต่อ ไม่พูดถึงการยกเลิกหรือย้ายไปที่อื่น" },
      { "label": "ความรวดเร็วและง่าย", "maxPoints": 15, "hint": "ได้สิ่งที่ต้องการโดยไม่ต้องรอนาน ไม่ถูกโอนหลายต่อ ไม่ต้องติดต่อซ้ำ" }
    ]
  },
  "updatedAt": "2026-06-10T04:54:12.745Z",
  "updatedBy": null
}
  • shortCallMaxSec — เกณฑ์สายสั้นของธุรกิจเอง หน่วยวินาที (null = ใช้ค่ากลางของระบบ) สายที่สั้นกว่าเกณฑ์จะได้แท็ก short และไม่นับในค่าเฉลี่ยคะแนน · shortCallMaxSecDefault คือค่ากลางของระบบ ส่วน shortCallMaxSecEffective คือค่าที่ใช้อยู่จริง ทั้งสองอ่านได้อย่างเดียว

customerScore — หมวดคะแนนลูกค้า

หมวดที่ใช้ให้คะแนนลูกค้า (customerScore ของแต่ละสาย) เป็นชุดเดียวกับที่ตั้งในส่วนคะแนนลูกค้าของหน้า /app/prompt มี 2 ชุด:

  • categories — ชุดการขาย ใช้ดูว่าลูกค้าสนใจ/พร้อมซื้อแค่ไหน (สายที่ได้ customerScoreKind: "sales")
  • serviceCategories — ชุดงานบริการ ใช้ดูว่าลูกค้าที่โทรเรื่องหลังการขาย แจ้งปัญหา หรือเรื่องบิล พึงพอใจแค่ไหน (สายที่ได้ customerScoreKind: "service")
  • แต่ละหมวดคือ { label, maxPoints, hint? } · maxPoints คือน้ำหนักเป็นเปอร์เซ็นต์ รวมทั้งชุดต้องได้ 100 พอดี · hint (สิ่งที่ระบบมองหาตอนให้คะแนน) มีเฉพาะในชุดตั้งต้นของระบบ
  • isDefault / isServiceDefault เป็น true เมื่อธุรกิจยังไม่ได้ตั้งชุดนั้นเอง ชุดที่เห็นคือชุดตั้งต้นของระบบ และจะเปลี่ยนตามเมื่อระบบปรับชุดตั้งต้น · defaultCategories / defaultServiceCategories คือชุดตั้งต้นของระบบ ณ ตอนนี้
  • การแก้หมวดมีผลกับสายที่ประเมินหลังจากนั้น คะแนนที่ให้ไปแล้วจะไม่ถูกคิดใหม่
  • customerScore จะมีเฉพาะตอนที่เปิดใช้คะแนนลูกค้าอยู่เท่านั้น ถ้าปิดอยู่จะไม่มีคีย์นี้ และอาจหายไปชั่วคราวเมื่ออ่านไม่สำเร็จ (ส่วนอื่นของ config ยังส่งกลับมาครบ) ให้อ่านใหม่

PUT /recordings/prompt-config

บันทึกหรืออัปเดต config การวิเคราะห์ด้วย AI (ธุรกิจละ 1 ชุด) · ช่องบริบทธุรกิจที่เว้นว่างจะเก็บเป็น null และ businessName จะใช้ชื่อธุรกิจแทน · scoreCategories มีได้ 1–20 หมวด · endpoint ตรวจรูปแบบข้อมูลแต่ไม่บังคับให้ผลรวมน้ำหนักเท่ากับ 100 (หน้าแก้ไขในแอปบังคับให้เอง)

⚠️ endpoint นี้ เขียนทับทั้ง config ยกเว้น 4 ฟิลด์ความยาว (summaryStyle · coachingStyle · summaryInstructions · coachingInstructions) shortCallMaxSec และ customerScore ซึ่ง ถ้าไม่ส่งมา ระบบจะไม่แตะค่าเดิม — ระบบที่เขียนไว้ก่อนมีฟิลด์เหล่านี้จึงไม่เผลอรีเซ็ตค่าที่ลูกค้าตั้งไว้ทุกครั้งที่ยิง PUT · ถ้าอยากล้างคำสั่งเพิ่มเติมให้ส่งค่าว่าง "" มาชัด ๆ · ทุกอย่างในคำขอเดียวจะบันทึกพร้อมกันทั้งหมด หรือไม่บันทึกเลย

Scope: manage_recording_prompt

fieldtypenotes
businessNamestring (≤200)ชื่อที่ AI ใช้ — ถ้าเว้นว่างจะใช้ชื่อธุรกิจแทน
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)คำสั่งของธุรกิจสำหรับส่วนคำแนะนำ — ต่อท้ายกฎกลาง ไม่ได้แทนที่ · ส่งค่าว่างเพื่อล้าง
shortCallMaxSecint 1–600 | nullเกณฑ์สายสั้นของธุรกิจ หน่วยวินาที · null = กลับไปใช้ค่ากลางของระบบ · ไม่ส่งมา = ไม่แตะของเดิม
customerScoreobjectหมวดคะแนนลูกค้า { categories?, serviceCategories? } — ดู แก้หมวดคะแนนลูกค้า ด้านล่าง · ไม่ส่งมา = ไม่แตะทั้ง 2 ชุด
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 }

ถ้าเปิดใช้คะแนนลูกค้าอยู่ response จะมี customerScore มาด้วย — คือค่าที่ใช้อยู่หลังบันทึก หน้าตาเหมือนใน GET ทุกอย่าง

แก้หมวดคะแนนลูกค้า

ใส่ customerScore ใน body · แยกทีละชุด: ไม่ส่งมา = ไม่แตะของเดิม · null = กลับไปใช้ชุดตั้งต้นของระบบ · ส่งเป็นรายการ = ชุดของธุรกิจเอง · ต้องส่งมาอย่างน้อย 1 ชุด

"customerScore": {
  "categories": [
    { "label": "ความต้องการชัดเจน", "maxPoints": 40 },
    { "label": "งบประมาณ", "maxPoints": 30 },
    { "label": "นัดขั้นตอนถัดไป", "maxPoints": 30 }
  ],
  "serviceCategories": null
}
  • 1 ชุดมีได้ 1–10 หมวด · label ยาว 1–80 ตัวอักษร · maxPoints เป็นจำนวนเต็ม 0–100 และ maxPoints ทั้งชุดต้องรวมได้ 100 พอดี
  • หมวดของธุรกิจเก็บแค่ชื่อกับน้ำหนัก ถ้าส่ง hint มาจะถูกตัดทิ้ง
  • ⚠️ ถ้าอยากใช้ชุดตั้งต้นของระบบต่อไป อย่าส่งชุดนั้นมา · ถ้าส่ง categories ที่อ่านได้ตอน isDefault เป็น true กลับมา ระบบจะบันทึกชุดตั้งต้นนั้นเป็นของธุรกิจเอง (โดยไม่มี hint) และจะไม่เปลี่ยนตามเมื่อระบบปรับชุดตั้งต้นอีก
  • ใช้ได้เฉพาะตอนที่เปิดใช้คะแนนลูกค้าอยู่ (คือตอนที่ GET คืน customerScore มา) · ถ้าส่งมาตอนที่ปิดอยู่ ระบบจะปฏิเสธทั้งคำขอด้วย 403 customer_score_disabled และไม่บันทึกอะไรเลย ให้ส่งใหม่โดยไม่ใส่ customerScore

จะคืน 422 validation_failed เมื่อข้อมูลไม่ถูกต้อง — เช่น ขาดคีย์ของแท็ก, scoreCategories ว่าง, maxPoints อยู่นอกช่วง 1–100 หรือหมวดคะแนนลูกค้าที่น้ำหนักรวมไม่ได้ 100 · กรณีนี้ระบบจะไม่บันทึกอะไรเลย

GET /recordings/detail

ทุกอย่างของสายเดียว จบในคำขอเดียว — ข้อมูลสายพร้อมผลวิเคราะห์ที่เก็บไว้ แปลงเป็น turn ให้เรียบร้อยแล้ว

แพ็กเกจ: ตอบ 403 feature_not_in_package ถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ

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, sentiment, flags, customerFieldsdefault summary — เลือกเฉพาะส่วนที่ต้องการได้ เพราะบทถอดเสียงมีขนาดใหญ่ที่สุด
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,
    "customerScore": 72,
    "customerScoreKind": "sales",
    "customerReadinessScore": 72,
    "customerSatisfactionScore": 81,
    "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",
    "customerScore": 72,
    "customerScoreBreakdown": [
      { "label": "ความต้องการชัดเจน", "weight": 30, "pct": 82, "reason": "บอกทำเลและขนาดบ้านที่ต้องการ" },
      { "label": "ความพร้อมด้านงบประมาณ", "weight": 25, "pct": 60, "reason": "มีงบแต่ยังกังวลเรื่องดอกเบี้ย" },
      { "label": "ช่วงเวลาตัดสินใจ", "weight": 20, "pct": null, "reason": "ไม่ได้พูดถึงกำหนดเวลา" },
      { "label": "การตอบรับขั้นตอนถัดไป", "weight": 25, "pct": 72, "reason": "ขอใบเสนอราคา" }
    ],
    "customerScoreKind": "sales",
    "customerScoreKindReason": "ลูกค้าโทรมาสอบถามโครงการเพื่อซื้อ",
    "customerReadinessScore": 72,
    "customerReadinessBreakdown": [
      { "label": "ความต้องการชัดเจน", "weight": 30, "pct": 82, "reason": "บอกทำเลและขนาดบ้านที่ต้องการ" },
      { "label": "ความพร้อมด้านงบประมาณ", "weight": 25, "pct": 60, "reason": "มีงบแต่ยังกังวลเรื่องดอกเบี้ย" },
      { "label": "ช่วงเวลาตัดสินใจ", "weight": 20, "pct": null, "reason": "ไม่ได้พูดถึงกำหนดเวลา" },
      { "label": "การตอบรับขั้นตอนถัดไป", "weight": 25, "pct": 72, "reason": "ขอใบเสนอราคา" }
    ],
    "customerSatisfactionScore": 81,
    "customerSatisfactionBreakdown": [
      { "label": "ปัญหา/คำถามได้รับการแก้ไข", "weight": 35, "pct": 80, "reason": "ได้คำตอบเรื่องโปรโมชั่นครบ" },
      { "label": "ความพึงพอใจต่อการบริการ", "weight": 30, "pct": 85, "reason": "ขอบคุณและน้ำเสียงดี" },
      { "label": "ความมั่นใจต่อบริการ", "weight": 20, "pct": 75, "reason": "ยังกังวลเรื่องดอกเบี้ยเล็กน้อย" },
      { "label": "ความรวดเร็วและง่าย", "weight": 15, "pct": null, "reason": "" }
    ],
    "agentScoreReasons": [
      { "label": "การทักทาย", "reason": "แนะนำตัวและบริษัทครบ" },
      { "label": "การนำเสนอ", "reason": "อธิบายโปรโมชั่นแต่ไม่ได้ถามงบของลูกค้า" }
    ],
    "scoreBreakdown": [
      { "label": "การทักทาย", "weight": 20, "pct": 100, "reason": "แนะนำตัวและบริษัทครบ" },
      { "label": "การนำเสนอ", "weight": 50, "pct": 84, "reason": "อธิบายโปรโมชั่นแต่ไม่ได้ถามงบของลูกค้า" },
      { "label": "การปิดการขาย", "weight": 30, "pct": 80, "reason": null }
    ]
  },
  "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
  }
}

summary.customerScoreBreakdown มีทุกหมวดของชุดคะแนนลูกค้าของธุรกิจ: weight น้ำหนักเป็นเปอร์เซ็นต์ (รวมกันได้ 100) · pct ที่สายนี้ได้ในหมวดนั้น (0-100) · reason เหตุผลสั้น ๆ · pct: null แปลว่าสายนี้ไม่มีข้อมูลให้ประเมินหมวดนั้น ระบบไม่นับหมวดนั้น (ไม่ได้นับเป็น 0) · customerScore กับ customerScoreBreakdown เป็น null ถ้าสายนี้ยังไม่เคยถูกประเมิน (ดู customerScore ด้านบน)

summary.customerReadinessScore / summary.customerSatisfactionScore คือคะแนนลูกค้าทั้ง 2 ฝั่งของเวอร์ชันนี้ — ความพร้อมซื้อตามหมวดการขาย และความพึงพอใจตามหมวดงานบริการ — พร้อมรายหมวด summary.customerReadinessBreakdown / summary.customerSatisfactionBreakdown รูปแบบเดียวกับ customerScoreBreakdown (รายหมวดของฝั่งหลักคือรายการเดียวกับ customerScoreBreakdown) · อีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัด แต่รายหมวดยังอาจมีครบทุกหมวดโดย pct: null · เวอร์ชันที่ประเมินก่อนมีคะแนน 2 ฝั่ง มี customerScore และ customerScoreBreakdown อยู่ในฝั่งตามประเภท ส่วนอีกฝั่งเป็น null

summary.customerScoreKind บอกว่าเป็นคะแนนลูกค้าประเภทไหน (sales หรือ service · null ถ้ายังไม่ได้ประเมิน) และ summary.customerScoreKindReason คือเหตุผลหนึ่งบรรทัดที่ระบบดูว่าเป็นประเภทนั้น (null ถ้าไม่มี) · summary.agentScoreReasons คือเหตุผลหนึ่งบรรทัดของแต่ละหมวดคะแนนเทเล เรียงตามหมวด ว่าทำไมสายนี้ได้คะแนนหมวดนั้นเท่านั้น รอบประเมินเดียวกันเป็นผู้เขียน และไม่เปลี่ยน score · เป็น null ในสายที่ประเมินก่อนมีข้อมูลนี้

summary.scoreBreakdown คือคะแนนเทเลรายหมวด — agentScoreReasons ยังส่งมาเหมือนเดิม ส่วน scoreBreakdown มีเหตุผลชุดเดียวกันนั้นพร้อมน้ำหนัก (weight เป็นเปอร์เซ็นต์) และคะแนนที่สายนี้ได้ (pct จำนวนเต็ม 0-100) ของแต่ละหมวด · กติกาเหมือน result.scoreBreakdown ของ POST /recordings/analyze ทุกอย่าง: หมวดที่ไม่มีเหตุผลได้ reason: null และเป็น null ทั้งรายการถ้าผลวิเคราะห์ไม่ได้เก็บคะแนนรายหมวดไว้ (หรือในบางครั้งที่ระบบอ่านไม่ได้ในตอนนั้น — ส่วนอื่นของสรุปยังส่งมาครบ) · เป็นรายการเดียวกับที่แท็บ "คะแนน" ในหน้าสายแสดง

โครงสร้างของส่วนอื่น:

ส่วนโครงสร้าง
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 }
flags[{ key, label, severity, quote, reason, startMs }] — Flag ชุดเดียวกับ call.flags พร้อมประโยคในสายที่ใช้ตัดสิน (quote) เหตุผลสั้น ๆ (reason) และตำแหน่งในไฟล์เสียงที่ประโยคนั้นเริ่ม (startMs, มิลลิวินาที)
customerFields[{ key, label, type, value, quote }] — คำตอบข้อมูลลูกค้าที่บันทึกไว้กับสายนี้ เรียงตามลำดับคำถามของธุรกิจ
curl "https://phone.mcloud.co.th/api/v1/recordings/detail?id=0192b9...&include=flags,customerFields" \
  -H "Authorization: Bearer crk_..."
{
  "call": {
    "id": "0192b9...",
    "flags": [
      { "key": "churn_risk", "label": "ลูกค้าจะยกเลิก", "severity": "critical" }
    ],
    "aiProcessed": true
  },
  "include": ["flags", "customerFields"],
  "flags": [
    {
      "key": "churn_risk",
      "label": "ลูกค้าจะยกเลิก",
      "severity": "critical",
      "quote": "ถ้ายังแก้ไม่ได้ ผมขอยกเลิกเลยนะครับ",
      "reason": "ลูกค้าบอกชัดว่าจะยกเลิกถ้าปัญหายังไม่ได้รับการแก้",
      "startMs": 83400
    }
  ],
  "customerFields": [
    { "key": "name", "label": "ชื่อลูกค้า", "type": "text", "value": "สมชาย", "quote": "ผมชื่อสมชายครับ" },
    { "key": "follow_up_date", "label": "นัดติดต่อกลับ", "type": "date", "value": "2026-05-22", "quote": "รบกวนโทรกลับวันที่ 22 นะครับ" },
    { "key": "budget", "label": "งบประมาณ", "type": "number", "value": 30000, "quote": "งบประมาณสักสามหมื่นครับ" }
  ]
}
  • 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 ที่คืนมา
  • flags กับ customerFields ไม่เป็น null เพราะเป็นข้อมูลในฐานข้อมูล ไม่ใช่ไฟล์ที่อาจหาย ถ้าไม่มีจะได้ [] · สายที่เชื่อมมาจากธุรกิจอื่นได้ [] ทั้งสองส่วนเสมอ
  • value ใน customerFields เป็นชนิดตาม type: ข้อความ (text, choice) ตัวเลข (number) วันที่ YYYY-MM-DD (date) หรือ boolean (yes_no) · null แปลว่ามีคนลบคำตอบของ AI ออกเพราะไม่ถูกต้อง
  • quote เป็น null เมื่อ Flag หรือคำตอบนั้นมีคนใส่เอง · คำตอบของคำถามที่ธุรกิจปิดหรือเก็บถาวรไปแล้วยังส่งมาด้วย เพราะเป็นข้อมูลที่ได้จากสายนั้นจริง
  • include ที่ไม่ใช่ 6 ชื่อนี้จะได้ 400 validation_failed · สายที่ API key มองไม่เห็นจะได้ 404 not_found ไม่ใช่ 403 เพราะ 403 เท่ากับยืนยันว่า id นั้นมีอยู่จริง

GET /recordings/insight-config

รายการ Flag และคำถามข้อมูลลูกค้าทั้งหมดที่ธุรกิจตั้งไว้ ใช้แปล key ที่เจอในที่อื่นของ API — flags ของแต่ละสาย, ตัวกรอง flag, include=flags,customerFields และ call.flags ใน callback — ให้เป็นชื่อที่คนอ่านและระดับของ Flag

Scopes: view_recording_all · view_recording_self

curl "https://phone.mcloud.co.th/api/v1/recordings/insight-config" \
  -H "Authorization: Bearer crk_..."
{
  "flags": [
    { "key": "customer_complaint", "label": "ลูกค้าร้องเรียน", "severity": "critical", "active": true, "archived": false },
    { "key": "deal_closed", "label": "ปิดการขายได้", "severity": "positive", "active": true, "archived": false },
    { "key": "f_k3x9q2m1za", "label": "ลูกค้าถามเรื่องผ่อน 0%", "severity": "info", "active": false, "archived": true }
  ],
  "fields": [
    { "key": "name", "label": "ชื่อลูกค้า", "type": "text", "options": [], "unit": null, "active": true, "archived": false },
    { "key": "follow_up_date", "label": "นัดติดต่อกลับ", "type": "date", "options": [], "unit": null, "active": true, "archived": false },
    { "key": "budget", "label": "งบประมาณ", "type": "number", "options": [], "unit": "บาท", "active": false, "archived": false }
  ]
}
ฟิลด์ความหมาย
keyตัวระบุถาวร เปลี่ยนไม่ได้ — Flag และคำถามที่มากับระบบใช้ชื่อภาษาอังกฤษ (customer_complaint, follow_up_date …) ส่วนที่ธุรกิจสร้างเองได้ key ที่สุ่มขึ้น ขึ้นต้นด้วย f_ (Flag) หรือ q_ (คำถาม)
labelชื่อที่ธุรกิจตั้ง แก้ได้ตลอด จึงควรอ่านจากที่นี่แทนการเขียนตายตัวไว้ในระบบของคุณ
severityระดับของ Flag: critical เร่งด่วน · warning ควรดู · positive ดี · info ข้อมูล
typeชนิดคำตอบ: text · number · date · choice (คำตอบเป็นค่าหนึ่งใน options) · yes_no
optionsตัวเลือกของคำถามแบบ choice ชนิดอื่นเป็น []
unitหน่วยของคำตอบที่เป็นตัวเลข เช่น บาท หรือ null
activeAI ตรวจข้อนี้ในสายใหม่อยู่ (เปิดอยู่และยังไม่เก็บถาวร)
archivedธุรกิจเลิกใช้แล้ว แต่ยังอยู่ในรายการเพราะสายเก่าอาจยังมี key นี้
  • ข้อที่ยังใช้อยู่ขึ้นก่อนตามลำดับที่ธุรกิจจัด ข้อที่เก็บถาวรต่อท้าย
  • endpoint นี้อ่านได้อย่างเดียว การเพิ่มหรือแก้ Flag และคำถามทำในแอป เพราะรายการนี้มีผลกับค่าวิเคราะห์ของทุกสาย

ค้นหาข้อความในบทสนทนา

การค้นว่า "มีใครพูดคำนี้ไว้ตรงไหนบ้าง" ครอบคลุมทุกสายที่ API key มองเห็น อยู่ที่ GET /transcripts/search — ดูหน้า Transcripts ซึ่งครอบคลุมพารามิเตอร์ทั้งหมด การแบ่งหน้าด้วย cursor และวิธีอ่าน matchedCalls / callsScanned

Insights

6 endpoint อ่านอย่างเดียวใต้ /insights/** ออกแบบให้คำตอบทั้งชุดพอดีกับ 1 response — สำหรับผู้ช่วย AI และหน้าสรุปตัวเลข ไม่ใช่สำหรับ export ทีละแถว

แพ็กเกจ: ทุก endpoint ใต้ /insights/** ตอบ 403 feature_not_in_package ถ้าแพ็กเกจของธุรกิจไม่รวมการส่งผลการวิเคราะห์ออก ดู เริ่มต้นใช้งาน → Endpoint ที่ขึ้นกับแพ็กเกจ

ทุกตัวเลขใช้นิยามเดียวกับหน้าภาพรวมในแอป (การแบ่งสาย 4 ทาง เกณฑ์รับสาย/สายหลุด ช่วงคะแนน funnel) คำตอบที่ได้จาก API นี้จึงตรงกับหน้า /app/dashboard เสมอ

Scopes (ทั้ง 6 endpoint): view_recording_all · view_recording_dashboard · view_recording_self API key ที่ไม่มี view_recording_all (หรือ manage_all) จะนับเฉพาะสายที่ผูกกับผู้ใช้เจ้าของ key เท่านั้น

พารามิเตอร์ร่วม — ทุก insights endpoint รับชุดนี้:

ParamTypeNote
dateFromวันที่หรือ ISO 8601optional — ดู วันที่และเวลา
dateToวันที่หรือ ISO 8601optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น)
tzIANA timezonedefault Asia/Bangkok — เป็นเขตเวลาที่ใช้ตัด bucket วัน/ชั่วโมงด้วย
agentKeystring, ส่งซ้ำได้ (สูงสุด 500)เอาเฉพาะพนักงานเหล่านี้ — key เดียวกับ /recordings/agents · /insights/agents ไม่รับพารามิเตอร์นี้ ให้ใช้ search แทน

ระบบอ่านช่วงเวลาอย่างไร

  • ถ้าไม่ส่งขอบเขตทั้งสองข้าง จะนับทั้งหมดตั้งแต่ต้น และไม่มีช่วงก่อนหน้าให้เทียบ: period.previous กับ comparison ทุกตัวจะเป็น null (ถ้าใส่ค่าเริ่มต้น 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 ซึ่งเป็นจุดตัดเดียวกับการกระจายคะแนนในหน้าภาพรวม · ทุก response บอกไว้ตรง ๆ ด้วย basis: "score" และ thresholds จะได้ไม่ต้องเดาว่ากำลังอ่านตัวไหนอยู่

POST /insights/overview · /insights/agents · /insights/timeseries

สามตัวนี้ตอบ POST ได้ด้วย โดยส่งพารามิเตอร์มาใน JSON body — สำหรับตัวกรองที่ใส่ใน URL ไม่ได้ โดยเฉพาะรายการเบอร์ลูกค้าจาก CRM · response เหมือน GET ของตัวเองทุกประการ · เป็นการอ่าน ที่ใช้ POST เพียงเพราะขนาดของคำขอ

Scopes: เหมือน GET — view_recording_all · view_recording_dashboard · view_recording_self

body รับพารามิเตอร์ทุกตัวของ GET ด้วยชื่อเดิม (agentKey เป็น array ไม่เกิน 500 ค่า) พร้อมตัวกรองสายอีก 4 ตัว:

ฟิลด์TypeNote
customerPhonesstring[] ไม่เกิน 10,000 ค่า (แต่ละค่าไม่เกิน 64 ตัวอักษร)เฉพาะสายของเบอร์ใดเบอร์หนึ่งในนี้ จับคู่ด้วย phone key — รูปแบบไหนก็ได้ (Phones → วิธีจับคู่เบอร์โทร)
tagsarray ของ normal | short | wrong_or_spam | no_answer | other | noneเฉพาะสายที่มีแท็กใดแท็กหนึ่งในนี้ · none = ยังไม่ติดแท็ก
callcenterNamesstring[] ไม่เกิน 500 ค่า (แต่ละค่า 1-200 ตัวอักษร)เฉพาะสายที่ชื่อพนักงานที่บันทึกไว้กับสายตรงกับชื่อใดชื่อหนึ่งในนี้ทุกตัวอักษร — แบบเดียวกับ POST /recordings/search · ค่าที่ใช้ได้ดูจาก GET /recordings/callcenter-names
directionin | outเฉพาะสายเข้าหรือสายออกตามที่ระบุ
curl -X POST https://phone.mcloud.co.th/api/v1/insights/timeseries \
  -H "Authorization: Bearer crk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "metric": "calls",
    "bucket": "day",
    "dateFrom": "2026-10-01",
    "dateTo": "2026-10-07",
    "customerPhones": ["0812345678", "+66 89-876-5432"],
    "tags": ["normal"]
  }'
  • ตัวกรองกรองช่วงก่อนหน้าแบบเดียวกับช่วงปัจจุบันทุกประการ comparison และ summary.previous จึงเทียบของชนิดเดียวกัน · บน /insights/agents ตัวกรองมีผลทั้งกับหน้านั้น total และ comparison ของพนักงานทุกคน
  • /insights/agents ไม่รับ agentKey ใน body เช่นกัน — ถ้าส่งมาจะได้ 400 ให้ใช้ search แทน
  • body ใช้กติกาของ Phones → Body ของคำขอแบบ batch: คีย์ที่ไม่รู้จักได้ 400 · null ในค่าเดี่ยว (dateFrom, tz, metric, limit, …) ถือว่าไม่ได้ตั้ง · customerPhones: [], tags: [], callcenterNames: [] หรือ agentKey: [] ไม่ตรงกับสายไหนเลย · agentKey เกิน 500 ค่าได้ 400 (ส่วน GET ตัดส่วนเกินทิ้งเงียบ ๆ)
  • /insights/topics, /insights/questions และ /insights/quality มีแค่ GET

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 และตัวเลขช่วงก่อนหน้าของแต่ละคน จึงตอบได้ในการอ่านครั้งเดียวว่า "ผลงานของใครตกลง"

ตัวตนของพนักงานคือ callcenterNumber ถ้าไม่มีเบอร์จึงใช้ callcenterName — key ตัวเดียวกับที่ /recordings/agents คืน จึงสลับไปมาระหว่าง endpoint ได้โดยไม่ต้องมีตารางแปลง · ชื่อที่แสดงเลือกแบบเดียวกับกระดานทีมในแอปทุกประการ: ใช้ชื่อที่แอดมินตั้งทับก่อน ถ้าไม่มีจึงใช้ชื่อจาก provider และถ้ายังไม่มีจึงใช้ชื่อที่บันทึกไว้กับสาย

ParamTypeNote
searchstringค้นได้ทั้งเบอร์ภายใน ชื่อที่บันทึกไว้กับสาย ชื่อในรายชื่อของ provider และชื่อที่ตั้งทับ
sortBycalls | answered | missed | talkTime | avgScore | answerRate | namedefault calls
sortDirasc | descdefault desc · ค่า null อยู่ท้ายสุด ถ้าเท่ากันจะเรียงตาม key ของพนักงาน
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 คือจำนวนพนักงานที่ไม่ซ้ำกันในช่วงนั้น ใช้คู่กับ offset เพื่อแบ่งหน้า
  • agentId และ isActive มาจากรายชื่อพนักงานของ provider · เบอร์ภายในที่มีอยู่แค่ในประวัติสายจะได้ agentId: null และ isActive: false
  • comparison แยกรายแถวและเป็น null เมื่อดูแบบทั้งหมดตั้งแต่ต้น · คำนวณเฉพาะพนักงานที่อยู่ในหน้านี้เท่านั้น

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: hour → 2026-08-01T09:00, day → 2026-08-01, week → วันจันทร์ของสัปดาห์นั้น, month → 2026-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 คือสัดส่วนของแท็กเดียวกันในช่วงก่อนหน้า เพราะถ้าหมวดอื่นโตขึ้น สัดส่วนก็ขยับได้แม้ปริมาณจะเท่าเดิม

GET /insights/questions

ลูกค้าพูดถึงเรื่องอะไรบ้างในช่วงนั้น และสายเหล่านั้นทำได้ดีแค่ไหน ต่างจาก /insights/topics ตรงที่ตัวนั้นรายงานแท็ก ชุดตายตัว 5 หมวด ส่วนตัวนี้เป็น open-vocabulary คือค้นหาเองว่าลูกค้าพูดเรื่องอะไร โดยไม่ต้องมีใครประกาศหัวข้อไว้ล่วงหน้า

นับเฉพาะคำพูดของลูกค้าเท่านั้น เพราะเทเลพูดสคริปต์เดิมทุกสาย ถ้านับทั้งสองฝั่ง คำทักทายจะชนะทุกหัวข้อจริง — วัดจากข้อมูลจริงบน production วลีที่พบบ่อยที่สุด 3 อันดับแรกของธุรกิจหนึ่งใน 1 เดือนคือ สวัสดีค่ะ, ครับ, ฮัลโหล ซึ่งเป็นฝั่งเทเลทั้งหมด

การจัดอันดับใช้ log-odds ratio พร้อม informative Dirichlet prior (Monroe, Colaresi & Quinn 2008) เทียบกับคลังข้อความรวมทั้งแพลตฟอร์ม — ไม่ใช่ความถี่ดิบ (ซึ่งจะได้แต่คำลงท้าย) และไม่ใช่ TF-IDF (ซึ่งให้น้ำหนักคำหายากมากเกินไป) ค่า z-score ส่งกลับมาในชื่อ distinctiveness

วลีที่เขียนต่างกันเล็กน้อยจะถูกรวมเป็นวลีเดียวโดยดูว่าตัวสะกดคล้ายกันแค่ไหน จึงรวมได้เฉพาะรูปเขียน/คำลงท้ายที่ต่างกัน ไม่ใช่คำพ้องความหมาย: กี่บาท กับ ราคาเท่าไหร่ ไม่มีตัวอักษรช่วงไหนตรงกันเลย จึงไม่ถูกรวม การตั้งชื่อหัวข้อจากคลัสเตอร์เหล่านี้เป็นหน้าที่ของผู้เรียก — 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 ที่แบ่งช่วงตามจุดตัดเดียวกับหน้าภาพรวม เพราะอารมณ์รายสายอยู่ในไฟล์ผลวิเคราะห์ จึงนำมารวมกลุ่มไม่ได้ — เงื่อนไขเดียวกับ /insights/overview
  • worstCalls คืน id เท่านั้น ให้ไปดึงสายจาก /recordings/{id} — คำว่า "เราตอบเรื่องนี้ได้ไม่ดี" จะมีประโยชน์ก็ต่อเมื่อกดฟังสายนั้นได้
  • ระบบ cache คำตอบแยกตามธุรกิจ ช่วงเวลา และสิทธิ์ของผู้เรียก: 1 ชั่วโมงถ้าช่วงเวลายังไม่ปิด และ 7 วันเมื่อช่วงเวลาปิดแล้ว ดูได้จาก x-cache: hit|miss
  • ถ้าช่วงเวลากว้างเกินกว่าจะคำนวณทันในเวลาที่กำหนด จะได้ 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 ช่วงเดิมเรียงลำดับนี้เสมอ กราฟจึงไม่ต้องรับมือกับช่วงที่หายไป
  • funnel ทั้ง 4 ขั้น แต่ละขั้นเป็นเซตย่อยของขั้นก่อนหน้าเสมอ ส่วนต่างระหว่าง 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 — ผลรวมจึงไม่เท่ากับ total
  • passRate หารด้วยจำนวนสายที่มีคะแนน ไม่ใช่ทุกสาย เพราะสายที่ยังไม่ถูกให้คะแนนไม่ใช่สายที่สอบตก และการหารด้วยยอดรวมจะกดอัตราลงทุกครั้งที่คิววิเคราะห์ทำงานไม่ทัน
  • samples คืนแค่ id, คะแนน และเวลาโทร · ถ้าต้องการบทถอดเสียง ให้ไปดึงต่อที่ GET /recordings/detail