Transcripts

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

Transcripts

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

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

GET /transcripts/search

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

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

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

Scopes: view_recording_all · view_recording_self

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

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

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

ลำดับนี้คือทั้งหมดของการออกแบบ: ถ้าไล่เปิดไฟล์บทสนทนาตรง ๆ จะกลายเป็นการสแกนทั้ง bucket ต่อการค้นหนึ่งครั้ง

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

% และ _ ไม่ใช่ wildcard ทั้งสองตัวเป็น wildcard ของ ILIKE แต่ระบบ escape ให้ก่อนเสมอ จึงถูกค้นแบบตัวอักษรตรง ๆ (ลด 50% หาคำว่า "ลด 50%" ไม่ใช่ "ลด 50 อะไรก็ได้") ถ้าปล่อยไว้ดิบ ขั้นที่ 1 จะเจอสายที่ขั้นที่ 2 ไม่มีทางเจอ ผลคือได้หน้าว่างทั้งที่ nextCursor ยังเดินต่อ — อ่านเหมือน "ไม่มีผลลัพธ์" ทั้งที่มี

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

กติกาเดียวกับทั้ง API v1 — ถ้าค่านั้นบอก timezone มาเอง ระบบใช้ตามนั้น ถ้าไม่บอก ระบบอ่านตาม tz (default 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เทียบตรงกับ COALESCE(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 เพราะป้ายอารมณ์/น้ำเสียงถูกเขียนเป็นข้อความไทยอิสระ (หงุดหงิด, ไม่พอใจเล็กน้อย) การเทียบแบบเท่ากันเป๊ะจะตอบว่า "ไม่มี" กับค่าจริงเกือบทุกค่า

ไม่มีพารามิเตอร์สำหรับจำกัดการค้นให้อยู่ในสายเดียว — ถ้ารู้ 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,
      "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
  • 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 เพราะ COUNT(*) แบบไม่มีเพดานบน trigram scan คือส่วนเดียวของ query นี้ที่ไม่มีขอบบน — เมื่อของจริงเกิน จะได้ 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

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

Error

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