Transcripts

ค้นหาข้อความข้ามทุกสาย · แบ่งหน้าด้วย cursor

Transcripts

ค้นหาข้อความในบทสนทนา — ครอบคลุมทุกสายที่ API key มองเห็น โดยไม่ต้องรู้ล่วงหน้าว่าอยู่สายไหน

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

GET /transcripts/search

ค้นหา turn ที่มีการพูดข้อความนั้น ครอบคลุมทุกสายที่ API key มองเห็น พร้อมแนบ turn รอบข้างมาด้วยเพื่อให้ยกไปอ้างอิงได้จริง

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

นี่คือการค้นแบบ global — ขอบเขตคือทุกสายของธุรกิจที่ยังไม่ถูกลบ ไม่ใช่สายใดสายหนึ่ง คำตอบที่ได้จึงเป็น "ประโยคที่มีคนพูด" ไม่ใช่ "รายชื่อสาย": แต่ละ item คือ 1 turn ที่ตรง เรียงจากสายใหม่สุดก่อน

เจอคำว่า ไม่พอใจ อย่างเดียวยังบอกไม่ได้ว่ามาจากไหน — ลูกค้าพูดเอง กับแอดมินถามว่าไม่พอใจไหม เป็นคนละเรื่องกัน ผลลัพธ์จึงมี before / after ติดมาด้วยเสมอ

Scopes: view_recording_all · view_recording_self

ทำงานอย่างไร และทำไมจึงเป็น substring

ทำงาน 2 ขั้น เพราะขั้นเดียวตอบไม่ครบ

  1. สายไหน — ระบบเก็บข้อความทั้งสายต่อกันเป็นก้อนเดียวและทำดัชนีไว้แล้ว จึงคัดจากสายหลักหมื่นเหลือแค่หน้าเดียวได้ในการค้นครั้งเดียว
  2. turn ไหน — ข้อความก้อนนั้นตั้งใจไม่เก็บเส้นแบ่ง turn จึงบอกไม่ได้ว่าใครพูด ระบบจึงเปิดไฟล์บทสนทนาของสายในหน้านี้เท่านั้น เพื่อหา turn, ผู้พูด, turn รอบข้าง และช่วงตัวอักษรที่ตรง

ลำดับนี้คือสิ่งที่ทำให้ค้นได้เร็ว: ถ้าไล่เปิดบทสนทนาทุกสายตรง ๆ การค้นแต่ละครั้งจะต้องอ่านข้อมูลทั้งธุรกิจ

ทำไมไม่ค้นเป็นคำ ภาษาไทยไม่มีช่องว่างระหว่างคำ การตัดคำจึงไม่แม่นพอสำหรับข้อมูลนี้ — วิธีที่ใช้ได้คือค้นแบบ substring คือหา q ตรงไหนของข้อความก็ได้ ผลที่ตามมาคือ q จะไปตรงกลางคำก็ได้ ซึ่งเป็นสิ่งที่ต้องการสำหรับภาษาไทย

% และ _ ไม่ใช่ wildcard ระบบค้นสองตัวนี้แบบตัวอักษรตรง ๆ ทั้ง 2 ขั้น (ลด 50% หาคำว่า "ลด 50%" ไม่ใช่ "ลด 50 อะไรก็ได้") จึงไม่มีกรณีได้หน้าว่างทั้งที่ nextCursor ยังเดินต่อ

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

กติกาเดียวกับทั้ง API v1 — ถ้าค่านั้นบอก timezone มาเอง ระบบใช้ตามนั้น ถ้าไม่บอก ระบบอ่านตาม tz (ค่าเริ่มต้น Asia/Bangkok)

  • 2026-08-01 = ทั้งวันตาม tz · dateFrom เริ่ม 00:00:00.000 · dateTo จบ 23:59:59.999 (รวมวันที่ระบุด้วย)
  • 2026-08-01T08:00:00Z หรือ +07:00 = ใช้ตามที่ส่งมาตรงตัว · ถ้าใช้ offset ใน query string ควร encode + เป็น %2B
  • วันที่ผิดรูปแบบหรือไม่มีอยู่จริงจะได้ 400 validation_failed · tz ที่ไม่รู้จักจะใช้ Asia/Bangkok แทน ไม่ใช่ error

ตารางเต็มอยู่ที่ Recordings → วันที่และเวลา

พารามิเตอร์

ParamTypeNote
qstring 2-100required — เทียบแบบตรงตัว ไม่สนตัวพิมพ์เล็กใหญ่ (% และ _ ไม่ใช่ wildcard) · ตัดช่องว่างหัวท้ายก่อนนับความยาว
dateFromวันที่หรือ ISO 8601optional — ดู วันที่และเวลา
dateToวันที่หรือ ISO 8601optional — ค่าที่เป็นวันล้วนหมายถึงสิ้นวัน (รวมวันนั้น)
tzIANA timezonedefault Asia/Bangkok — ใช้เฉพาะตอนขอบเขตไม่ได้ระบุ timezone มาเอง
agentExtensionstring 1-120เทียบตรงกับ key ของพนักงาน คือ callcenterNumber ของสาย ถ้าสายไม่มีเบอร์จึงใช้ callcenterName (key เดียวกับที่ /recordings/agents คืน)
phonestring 1-40เทียบเบอร์ที่ normalize แล้วแบบตรงตัว หรือ เทียบบางส่วนกับเบอร์ดิบ
sentimentstring 1-60เก็บเฉพาะ turn ที่อารมณ์ หรือ น้ำเสียงมีข้อความนี้อยู่ (substring ไม่สนตัวพิมพ์)
limitint 1-50default 20 — จำนวน turn ต่อหน้า
contextint 0-5default 2 — จำนวน turn รอบข้างที่แนบมาด้านละเท่านี้
cursorstringค่าที่ระบบสร้างให้ อย่าแก้เอง — ส่ง nextCursor ของหน้าก่อนกลับมา ดู การแบ่งหน้า

sentiment เทียบแบบ substring เพราะ AI เขียนป้ายอารมณ์/น้ำเสียงเป็นข้อความไทยอิสระ (หงุดหงิด, ไม่พอใจเล็กน้อย) ถ้าเทียบแบบต้องตรงทุกตัวอักษร จะได้คำตอบว่า "ไม่มี" กับค่าจริงเกือบทุกค่า

ไม่มีพารามิเตอร์สำหรับจำกัดการค้นให้อยู่ในสายเดียว — ถ้ารู้ callId อยู่แล้วให้ไปที่ Recordings → GET /recordings/detail ซึ่งคืนทุก turn ของสายนั้น

ตัวอย่าง

curl "https://phone.mcloud.co.th/api/v1/transcripts/search?q=%E0%B9%84%E0%B8%A1%E0%B9%88%E0%B8%9E%E0%B8%AD%E0%B9%83%E0%B8%88&dateFrom=2026-08-01&limit=20" \
  -H "Authorization: Bearer crk_..." \
  -H "X-Business-Id: <business uuid, account keys only>"
{
  "query": "ไม่พอใจ",
  "items": [
    {
      "callId": "0192b9...",
      "callAt": "2026-08-14T03:22:11.000Z",
      "direction": "in",
      "customerPhone": "+66812345678",
      "customerPhoneNormalized": "0812345678",
      "agent": { "extension": "12110", "name": "Somchai" },
      "score": 62,
      "customerScore": 35,
      "customerScoreKind": "service",
      "customerReadinessScore": null,
      "customerSatisfactionScore": 35,
      "before": [
        { "index": 41, "speaker": "แอดมิน", "side": "admin", "startMs": 181000, "endMs": 184200,
          "text": "ขออภัยด้วยนะคะ", "textOffset": 0, "textTruncated": false, "tone": "polite", "emotion": "neutral" }
      ],
      "turn": {
        "index": 42, "speaker": "ลูกค้า", "side": "customer", "startMs": 184200, "endMs": 189500,
        "text": "ผมไม่พอใจกับบริการรอบนี้มาก", "textOffset": 0, "textTruncated": false,
        "tone": "angry", "emotion": "negative",
        "highlight": { "start": 2, "length": 7 }
      },
      "after": [
        { "index": 43, "speaker": "แอดมิน", "side": "admin", "startMs": 189500, "endMs": 193000,
          "text": "รบกวนเล่าให้ฟังหน่อยได้ไหมคะ", "textOffset": 0, "textTruncated": false, "tone": "polite", "emotion": "neutral" }
      ]
    }
  ],
  "limit": 20,
  "context": 2,
  "matchedCalls": 37,
  "matchedCallsCapped": false,
  "callsScanned": 25,
  "nextCursor": "eyJjYWxsQXQiOiIyMDI2LTA4LTE0..."
}

อ่านผลลัพธ์อย่างไร

  • 1 item = 1 turn ที่ตรง เรียงตามลำดับในบทสนทนา · turn ที่พูดคำนั้น 3 ครั้งยังนับเป็น item เดียว เพราะเป็นสิ่งที่ลูกค้าพูดครั้งเดียว และการแยกเป็น 3 บรรทัดจะดันสายอื่นตกหน้าไป
  • ผลลัพธ์เรียงจากสายใหม่สุดก่อน (callAt มากไปน้อย แล้วปิดท้ายด้วย id เพื่อไม่ให้แถวซ้ำข้ามหน้า)
  • highlight.start / highlight.length เป็นตำแหน่งภายใน text ที่คืนมา ไม่ใช่ใน turn เต็ม: turn ที่ตรงจำกัดไว้ที่ 1200 ตัวอักษร turn รอบข้าง 400 ตัวอักษร · เมื่อ textTruncated เป็น true ให้ดู textOffset ว่าช่วงนั้นเริ่มที่ตัวอักษรที่เท่าไรของ turn เต็ม (highlight จึงชี้ตัวอักษรชุดเดิมเสมอ ไม่ว่าจะถูกตัดหรือไม่)
  • speaker คือคำเรียกที่ธุรกิจตั้งไว้เองสำหรับฝั่งนั้น (ตั้งที่ /app/prompt) ค่าเริ่มต้นจึงเป็นภาษาไทย · side เป็นค่าที่เครื่องอ่านได้: admin / customer / unknown
  • score / customerScore / customerScoreKind คือคะแนนเทเลและคะแนนลูกค้าของสาย ค่าเดียวกับที่ GET /recordings คืนมา — customerScore กับ customerScoreKind เป็น null เมื่อสายนั้นยังไม่มีคะแนนลูกค้า (ดู Recordings → customerScore)
  • customerReadinessScore / customerSatisfactionScore คือคะแนนลูกค้าทั้ง 2 ฝั่ง (ความพร้อมซื้อ · ความพึงพอใจ) แบบเดียวกับ GET /recordings: ฝั่งที่ customerScoreKind บอกเท่ากับ customerScore อีกฝั่งเป็น null เมื่อสายนั้นไม่มีอะไรให้วัด — เหมือนตัวอย่างด้านบนที่เป็นสายแจ้งปัญหาซึ่งไม่มีเรื่องซื้อขาย
  • agent.extension / agent.name คือค่าที่บันทึกไว้กับสายตอนดึงเข้าระบบ ไม่ใช่ชื่อที่แอดมินตั้งทับภายหลัง (ถ้าต้องการชื่อที่ใช้แสดงผลให้ดูที่ /recordings/agents)
  • index คือเลข turn ที่กำหนดไว้ตอนสร้างไฟล์บทสนทนา — เป็นตัวเดียวกับที่ GET /recordings/detail ใช้ จึงอ้างอิงข้ามกันได้
  • ระบบจะข้ามสายที่ยังไม่มีไฟล์บทสนทนา หรือไฟล์อ่านไม่ได้ไปเงียบ ๆ (แต่ยังนับใน callsScanned)

การแบ่งหน้า

การแบ่งหน้าเป็น keyset cursor ไม่ใช่ offset — และมีข้อเดียวที่ต้องจำ: ตามไปจนกว่า nextCursor จะเป็น null

กติกาความหมาย
nextCursor เป็น nullจบแล้วจริง ๆ ไม่มีอะไรอยู่ข้างหลังอีก
nextCursor มีค่ายังมีต่อ ส่งค่านั้นกลับมาที่ cursor แล้วยิงซ้ำ พารามิเตอร์อื่นต้องเหมือนเดิมทุกตัว
หน้าได้ไม่เต็ม limit หรือ items ว่างเปล่าห้ามตีความว่าจบ — แต่ละคำขอเปิดไฟล์บทสนทนาได้มากสุด 25 สาย (callsScanned) ถ้างบนี้หมดก่อนที่หน้าจะเต็ม ระบบคืนเท่าที่ได้พร้อม nextCursor ที่เดินต่อ · การหยุดเพราะหน้าหนึ่งได้น้อยคือวิธีที่ทำให้ผลลัพธ์หายไปเงียบ ๆ
สายเดียวมีผลลัพธ์เกินหนึ่งหน้าcursor จำได้ว่าไปถึง turn ไหนของสายนั้น จึงเดินต่อภายในสายเดิม สายนั้นถูกไล่จนครบ ไม่ถูกตัดทิ้ง
cursor ถอดรหัสไม่ได้400 invalid_cursor — cursor เป็นค่าที่ระบบสร้างให้ อย่าแก้ อย่าประกอบเอง ส่งกลับมาตามที่ได้รับ

cursor เป็น base64url ซึ่งปลอดภัยกับ URL อยู่แล้ว จึงส่งกลับได้ตรง ๆ โดยไม่ต้อง percent-encode

วนจนครบด้วย shell

ต้องมี jq

TOKEN="crk_..."
BASE="https://phone.mcloud.co.th/api/v1/transcripts/search"
Q=$(printf '%s' "ไม่พอใจ" | jq -sRr @uri)
CURSOR=""

while : ; do
  PAGE=$(curl -s "$BASE?q=$Q&dateFrom=2026-08-01&limit=50${CURSOR:+&cursor=$CURSOR}" \
    -H "Authorization: Bearer $TOKEN")

  echo "$PAGE" | jq -c '.items[] | { callId, callAt, text: .turn.text }'

  CURSOR=$(echo "$PAGE" | jq -r '.nextCursor // empty')
  [ -n "$CURSOR" ] || break
done

วนจนครบด้วย JavaScript

const BASE = 'https://phone.mcloud.co.th/api/v1/transcripts/search'
const headers = { Authorization: 'Bearer crk_...' }

let cursor = null
const hits = []

do {
  const url = new URL(BASE)
  url.searchParams.set('q', 'ไม่พอใจ')
  url.searchParams.set('dateFrom', '2026-08-01')
  url.searchParams.set('limit', '50')
  if (cursor) url.searchParams.set('cursor', cursor)

  const res = await fetch(url, { headers })
  if (!res.ok) throw new Error((await res.json()).error.code)

  const page = await res.json()
  // หน้านี้อาจว่างได้ ทั้งที่ยังมีผลลัพธ์อยู่ข้างหลัง — เงื่อนไขจบคือ nextCursor เท่านั้น
  hits.push(...page.items)
  cursor = page.nextCursor
} while (cursor)

console.log(`เจอ ${hits.length} turn`)

ตัวเลข 3 ตัวที่ต้องไม่อ่านผิด

ฟิลด์คืออะไรไม่ใช่อะไร
matchedCallsจำนวนสายที่มีข้อความนี้อยู่ไม่ใช่จำนวน turn และไม่ใช่ยอดรวมสำหรับแบ่งหน้า
matchedCallsCappedtrue เมื่อจำนวนจริงมากกว่าเพดาน 500—
callsScannedจำนวนสายที่เปิดไฟล์บทสนทนาในคำขอนี้ สูงสุด 25ไม่ใช่จำนวนสายที่ตรงทั้งหมด
  • matchedCalls เป็นสัญญาณว่า "เรื่องนี้กระจายแค่ไหน" · เพดานอยู่ที่ 500 เพราะการนับสายที่ตรงทั้งหมดแบบไม่จำกัดคือส่วนเดียวของการค้นนี้ที่ใช้เวลาได้ไม่มีขอบบน — เมื่อของจริงเกิน จะได้ matchedCalls: 500 คู่กับ matchedCallsCapped: true
  • ห้ามคำนวณจำนวนหน้าจาก matchedCalls สายหนึ่งมีได้หลาย turn ที่ตรง ceil(matchedCalls / limit) จึงผิดทั้งขึ้นและลง · เงื่อนไขจบมีอย่างเดียวคือ nextCursor === null
  • sentiment ไม่มีผลกับ matchedCalls เพราะป้ายอารมณ์อยู่ในไฟล์บทสนทนา ไม่ใช่คอลัมน์ในฐานข้อมูล จึงกรองได้เฉพาะระดับ turn ในขั้นที่ 2 เท่านั้น · ตัวเลขนี้จึงตอบเสมอว่า "มีกี่สายที่พูดคำนี้" ไม่ใช่ "กี่สายที่ผ่านตัวกรองทั้งหมด"

สิทธิ์และขอบเขตที่มองเห็น

ต้องมี view_recording_all หรือ view_recording_self อย่างน้อยหนึ่งอย่าง (manage_all ครอบทั้งหมด) ถ้าไม่มีจะได้ 403 insufficient_scope

  • API key ที่มี view_recording_all ค้นได้ทุกสายของธุรกิจนั้น
  • API key ที่มีแต่ view_recording_self ค้นได้เฉพาะสายที่ผูกกับผู้ใช้เจ้าของ key — คำว่า "global" จึงหมายถึงทุกสายที่ API key นั้นมองเห็น ไม่ใช่ทุกสายในระบบ
  • สายที่อยู่ในถังขยะ (soft-delete) ไม่ถูกค้น
  • Account key ต้องส่ง X-Business-Id เพื่อบอกว่าจะค้นธุรกิจไหน · ถ้าไม่ส่งจะได้ 400 business_required · ถ้าส่งธุรกิจที่เจ้าของ key ไม่ได้เป็นสมาชิกจะได้ 403 forbidden_business · Business key ไม่ต้องส่ง เพราะผูกกับธุรกิจเดียวอยู่แล้ว

ข้อผิดพลาด

CodeStatusเมื่อไร
validation_failed400q สั้นกว่า 2 หรือยาวเกิน 100, ไม่ได้ส่ง q, limit/context นอกช่วง, วันที่ผิดรูปแบบ — response มี issues บอกว่าฟิลด์ไหนผิด
invalid_cursor400cursor ถอดรหัสไม่ได้ หรือโครงสร้างข้างในไม่ถูกต้อง
business_required400account key ที่ไม่ได้ส่ง X-Business-Id
missing_token · invalid_token · token_disabled401ปัญหาที่ตัว API key
insufficient_scope403API key ไม่มี view_recording_all และไม่มี view_recording_self
forbidden_business403account key ชี้ไปที่ธุรกิจที่เจ้าของ key เข้าไม่ถึง