QA:讓聊天室成為跨表營運分析員
情境
ACME 的營運主管不想為每一個臨時問題等待新報表。倉儲、CRM、專案、預約與電商團隊都已經把資料建成 custom tables;現在前端要在各自的聊天室開啟資料分析能力,讓使用者用自然語言詢問排名、加總、分布、連結追蹤、公式指標與相關性。目標不是新增另一張「問答表」,而是讓分析員在呼叫者原本就有權限的資料圖上工作。
資料模型
QA 層本身不新增資料表;它讀取聊天室目前可載入的 domain graph:
- WMS:Warehouses、Products、Transfers、Stock,回答庫存與流量問題
- CRM:Accounts、Deals、Contacts、Activities,回答漏斗與互動問題
- PMS:Epics、Sprints、Issues、WorkLogs,回答進度、阻塞與成本問題
- Booking:Practitioners、Patients、Appointments、Schedules,回答使用率與營收問題
- Ecommerce:Brands、Stores、Products、Orders、Order Items,回答 GMV 與商品表現
使用者問題
│
▼
聊天室分析員 ──沿呼叫者 ACL──▶ 當前 domain graph
│ ├─ filter / aggregate
└────────自然語言答案◀─────────┼─ link / lookup / rollup
└─ formula / profile / correlationNote 分析員不會繞過 ACL。相同問題由不同使用者提出時,可用資料列與欄位不同,答案也應跟著縮小;前端不應用管理者答案快取給其他身分。
交給分析員的完整 bundle
分析員本身仍然不擁有任何資料表,所以這是一份領域文件:它建立本頁所有問題所依據的營收資料切片,並且用「一張要拿來分析的圖」該有的方式建模。四張表、讓排名/加總/最近一次/比率這類問題「能被回答」的衍生欄位、本身就是那些問題的 saved view,以及讓分析員能讀遍整張圖、卻改不了任何一格的 grant。把它貼進 IaC 工作台,先跑 iac.plan,再帶著回傳的 plan_hash 與位元組完全相同的文件呼叫 iac.apply。套用前請先把 header.system、$dept: 與 $room: 身分 token,以及 select 的選項清單換成貴公司的用語;其餘內容都與環境無關。
{"kind":"header","version":1,"system":"acme-revenue","description":"ACME revenue graph the chatroom analyst reads"}
{"kind":"table","ref":"accounts","spec":{"name":"Accounts","key":"name","description":"Customer companies the analyst ranks and compares"}}
{"kind":"column","table":"accounts","ref":"name","spec":{"name":"Name","type":"string","required":true}}
{"kind":"column","table":"accounts","ref":"industry","spec":{"name":"Industry","type":"select","options":["manufacturing","retail","healthcare","software"]}}
{"kind":"column","table":"accounts","ref":"region","spec":{"name":"Region","type":"select","options":["north","central","south"]}}
{"kind":"table","ref":"contacts","spec":{"name":"Contacts","key":"email","description":"People at an account"}}
{"kind":"column","table":"contacts","ref":"email","spec":{"name":"Email","type":"string","required":true}}
{"kind":"column","table":"contacts","ref":"full_name","spec":{"name":"Full Name","type":"string"}}
{"kind":"column","table":"contacts","ref":"account","spec":{"name":"Account","type":"link","target":"accounts","cardinality":"one"}}
{"kind":"column","table":"contacts","ref":"account_name","spec":{"name":"Account Name","type":"lookup","link_field":"account","target_column":"name"}}
{"kind":"table","ref":"deals","spec":{"name":"Deals","key":"name","description":"Opportunities carrying amount, stage and probability"}}
{"kind":"column","table":"deals","ref":"name","spec":{"name":"Name","type":"string","required":true}}
{"kind":"column","table":"deals","ref":"amount","spec":{"name":"Amount","type":"float"}}
{"kind":"column","table":"deals","ref":"stage","spec":{"name":"Stage","type":"select","options":["qualifying","proposal","negotiation","closed_won","closed_lost"]}}
{"kind":"column","table":"deals","ref":"probability","spec":{"name":"Probability","type":"float","description":"0 to 1; null means not estimated yet, never zero"}}
{"kind":"column","table":"deals","ref":"opened_on","spec":{"name":"Opened On","type":"date"}}
{"kind":"column","table":"deals","ref":"closes_on","spec":{"name":"Closes On","type":"date"}}
{"kind":"column","table":"deals","ref":"account","spec":{"name":"Account","type":"link","target":"accounts","cardinality":"one"}}
{"kind":"column","table":"deals","ref":"account_industry","spec":{"name":"Account Industry","type":"lookup","link_field":"account","target_column":"industry"}}
{"kind":"column","table":"deals","ref":"weighted","spec":{"name":"Weighted","type":"formula","expression":"[amount] * [probability]"}}
{"kind":"table","ref":"activities","spec":{"name":"Activities","key":"subject","description":"Touchpoints, with the raw integration envelope kept verbatim"}}
{"kind":"column","table":"activities","ref":"subject","spec":{"name":"Subject","type":"string","required":true}}
{"kind":"column","table":"activities","ref":"occurred_on","spec":{"name":"Occurred On","type":"date"}}
{"kind":"column","table":"activities","ref":"channel","spec":{"name":"Channel","type":"select","options":["call","email","meeting","support"]}}
{"kind":"column","table":"activities","ref":"payload","spec":{"name":"Payload","type":"json","description":"Raw integration envelope; the shape differs per channel"}}
{"kind":"column","table":"activities","ref":"deal","spec":{"name":"Deal","type":"link","target":"deals","cardinality":"one"}}
{"kind":"column","table":"accounts","ref":"timeline","spec":{"name":"Timeline","type":"link","target":"activities","cardinality":"many"}}
{"kind":"column","table":"accounts","ref":"deal_count","spec":{"name":"Deal Count","type":"rollup","direction":"incoming","source":"deals","match":{"account":"$self"},"aggregation":"count"}}
{"kind":"column","table":"accounts","ref":"total_amount","spec":{"name":"Total Amount","type":"rollup","direction":"incoming","source":"deals","match":{"account":"$self"},"aggregation":"sum","target_column":"amount"}}
{"kind":"column","table":"accounts","ref":"won_amount","spec":{"name":"Won Amount","type":"rollup","direction":"incoming","source":"deals","match":{"account":"$self"},"aggregation":"sum","target_column":"amount","filter":[{"column":"stage","op":"eq","value":"closed_won"}]}}
{"kind":"column","table":"accounts","ref":"open_pipeline","spec":{"name":"Open Pipeline","type":"rollup","direction":"incoming","source":"deals","match":{"account":"$self"},"aggregation":"sum","target_column":"amount","filter":[{"column":"stage","op":"in","value":["qualifying","proposal","negotiation"]}]}}
{"kind":"column","table":"accounts","ref":"activity_count","spec":{"name":"Activity Count","type":"rollup","link_field":"timeline","aggregation":"count"}}
{"kind":"column","table":"accounts","ref":"last_activity_on","spec":{"name":"Last Activity On","type":"lookup","link_field":"timeline","target_column":"occurred_on","pick":{"order_column":"occurred_on","direction":"desc"}}}
{"kind":"column","table":"accounts","ref":"last_activity_subject","spec":{"name":"Last Activity Subject","type":"lookup","link_field":"timeline","target_column":"subject","pick":{"order_column":"occurred_on","direction":"desc"}}}
{"kind":"column","table":"accounts","ref":"win_rate","spec":{"name":"Win Rate","type":"formula","expression":"[won_amount] / [total_amount]"}}
{"kind":"column","table":"accounts","ref":"unclosed_amount","spec":{"name":"Unclosed Amount","type":"formula","expression":"[total_amount] - [won_amount]"}}
{"kind":"column","table":"deals","ref":"touch_count","spec":{"name":"Touch Count","type":"rollup","direction":"incoming","source":"activities","match":{"deal":"$self"},"aggregation":"count"}}
{"kind":"rule","table":"accounts","ref":"unique_account_name","spec":{"type":"unique","name":"Account name is unique","columns":["name"],"case_insensitive":true}}
{"kind":"rule","table":"deals","ref":"probability_is_a_share","spec":{"type":"check","name":"Probability never exceeds 1","column":"probability","op":"lte","value":1}}
{"kind":"rule","table":"deals","ref":"closes_after_open","spec":{"type":"compare","name":"A deal cannot close before it opens","left":"opened_on","op":"lte","right":"closes_on"}}
{"kind":"rule","table":"activities","ref":"subject_required","spec":{"type":"require","name":"Every touchpoint carries a subject","column":"subject"}}
{"kind":"view","table":"deals","ref":"closing_next_30_days","spec":{"name":"Closing in the next 30 days","is_shared":true,"config":{"stored_filters":[{"column":"closes_on","op":"within_next","value":{"amount":30,"unit":"days"}}],"any_of":[{"stored_filters":[{"column":"stage","op":"eq","value":"negotiation"}]},{"stored_filters":[{"column":"stage","op":"eq","value":"proposal"},{"column":"probability","op":"gte","value":0.5}]}],"sort":[{"column":"closes_on","order":"asc"},{"column":"amount","order":"desc"}],"columns":["name","account","amount","stage","probability","closes_on"]}}}
{"kind":"view","table":"deals","ref":"unattributed_pipeline","spec":{"name":"Pipeline with no account","is_shared":true,"config":{"stored_filters":[{"column":"account","op":"is_empty"}],"computed_filters":[{"column":"weighted","op":"gte","value":100}],"sort_by":"weighted","sort_order":"desc","columns":["name","amount","probability","weighted"]}}}
{"kind":"view","table":"activities","ref":"recent_touchpoints","spec":{"name":"Touchpoints in the last 30 days","is_shared":true,"config":{"stored_filters":[{"column":"occurred_on","op":"within_last","value":{"amount":30,"unit":"days"}}],"any_of":[{"stored_filters":[{"column":"channel","op":"in","value":["call","meeting"]}]},{"stored_filters":[{"column":"channel","op":"eq","value":"support"}]}],"sort":[{"column":"occurred_on","order":"desc"},{"column":"channel","order":"asc"}],"columns":["subject","occurred_on","channel","deal"]}}}
{"kind":"view","table":"activities","ref":"stale_touchpoints","spec":{"name":"Nothing newer than 90 days","is_shared":true,"config":{"stored_filters":[{"column":"occurred_on","op":"older_than","value":{"amount":90,"unit":"days"}},{"column":"payload","op":"is_null"}],"sort_by":"occurred_on","sort_order":"asc","columns":["subject","occurred_on","channel"]}}}
{"kind":"view","table":"accounts","ref":"analyst_leaderboard","spec":{"name":"Accounts by total amount","is_shared":true,"config":{"computed_filters":[{"column":"total_amount","op":"gte","value":100}],"sort_by":"total_amount","sort_order":"desc","columns":["name","industry","region","deal_count","total_amount","won_amount","win_rate","last_activity_on","last_activity_subject"]}}}
{"kind":"grant","table":"accounts","principal":{"type":"department","id":"$dept:Revenue Operations"},"spec":{"can_read":"all","can_insert":false,"can_edit":"none"}}
{"kind":"grant","table":"contacts","principal":{"type":"department","id":"$dept:Revenue Operations"},"spec":{"can_read":"all","can_insert":false,"can_edit":"none"}}
{"kind":"grant","table":"deals","principal":{"type":"department","id":"$dept:Revenue Operations"},"spec":{"can_read":"all","can_insert":false,"can_edit":"none"}}
{"kind":"grant","table":"activities","principal":{"type":"department","id":"$dept:Revenue Operations"},"spec":{"can_read":"all","can_insert":false,"can_edit":"none"}}
{"kind":"grant","table":"activities","principal":{"type":"chatroom","id":"$room:Sales/North Desk","audience":"internal"},"spec":{"can_read":"all","can_insert":false,"can_edit":"none","visible_columns":["subject","occurred_on","channel","deal"]}}
{"kind":"grant","table":"accounts","principal":{"type":"chatroom","id":"$room:Sales/North Desk","audience":"internal"},"spec":{"can_read":"filtered","can_insert":false,"can_edit":"none","read_filter":{"and":[{"column":"region","op":"eq","value":"north"}]}}}
{"kind":"insight_selection","chatroom":"$room:Sales/North Desk"}值得理解的幾行
accounts.timeline是指向 Activities 的 cardinality-many link。lookup 只能沿著「當前這張表自己擁有」的 link 走,所以有了這一行,下面兩個 picked lookup 才可能成立。accounts.last_activity_on與accounts.last_activity_subject是 picked lookup:pick依occurred_on排序被連結的 activity 並取第一筆,因此每一格是單一純量而不是陣列。這正是把「最近一次接觸是哪天、內容是什麼」變成可 filter、可 sort、可當 formula 運算元的關鍵,詳見 lookup。pick建立後無法修改,要改只能刪除欄位再重建。accounts.won_amount與accounts.open_pipeline是同一個 incoming rollup 搭配不同的filter述詞。rollup filter 屬於 config 面,只接受基本十個運算子,沒有相對日期,也沒有 presence 運算子。accounts.win_rate與accounts.unclosed_amount把兩個 rollup 變成一個比率與一個差額。兩者都會傳遞 null,所以沒有商機的客戶讀到的是null,不是0。deals.touch_count從相反方向 rollup——沿deal從 Activities incoming——讓這張圖不必多開一條 link 就能雙向走。activities.payload是json欄位,也是 agent 工具箱明顯勝過 REST 的地方。在 REST 上,json只能整格做等值比較;在聊天室裡,分析員可以用json_path指向單一葉節點並用gt/lt比較、用json_length篩陣列長度、依葉節點分群,甚至不帶 path 直接呼叫profile_column取得結構剖析(形狀分布、關鍵欄位排名、每個 key 的型別直方圖)——即使沒有人寫過這個 payload 的文件。這就是原封不動保留整個 envelope、而不是攤平它的理由,詳見 json。deals.closing_next_30_days是滾動視窗 view:within_next以相對形式儲存,每次套用時才解析成絕對邊界,所以這個 view 不需要有人定期改寫就永遠正確,plan hash 也不會逐日漂移。它同時帶了兩個any_of群組——(negotiation)OR(proposal AND probability ≥ 0.5)——以及雙鍵sort。deals.unattributed_pipeline把accountlink 上的is_empty(純索引存在性檢查,沒有資料列上限)與針對weightedformula 的computed_filters條件放在一起。activities.stale_touchpoints用older_than表達「自某時起就沒動靜」,旁邊是json欄位上的is_null。is_null與is_empty不是同義詞:前者問純量格是否缺值,後者問 link 或 attachment 裡到底有沒有東西。accounts.analyst_leaderboard用單鍵sort_by依 rollup 排序。計算欄位永遠不能當多鍵sort的鍵,這也是為什麼這個 view 用單鍵形式、而商機 view 用sort。- 四筆
$dept:Revenue Operationsgrant 就是分析員的姿態:整張圖can_read: "all"、can_insert: false、can_edit: "none"。「讀得廣、寫不了」是刻意的組合,不是預設值。 - 兩筆
$room:Sales/North Deskgrant 正是上面 Note 成立的原因。這個 room 看不到 Activities 的原始 payload,也只看得到自己區域的客戶,所以同一個問題在這裡問到的答案比分析員窄——前端不能把其中一份答案快取給另一方。 insight_selection把該 room 加入本文件的 system tag,助理才載得到這些表。它只在header.system非空的 department-scope 文件上合法;chatroom 或 company scope 的文件必須用 server plan 檢查。
產品流程
1. 先建立並填入一個真實 domain graph
以 CRM 為例,先依 CRM 情境用 iac.plan → iac.apply 建立 Accounts、Deals、Contacts、Activities,再用 records.create 寫入商機與互動。分析員只讀已存在的表,不維護另一份影子資料。
2. 在聊天室啟用 custom tables 工作
聊天室建立完成後,啟用 custom tables job 並給一段領域指示。CRM 的設定可以是:
PATCH /private/chatrooms/setting/jobs/11111111-1111-4111-8111-111111111111
Content-Type: application/json
{ "jobs": ["custom_tables"] }PATCH /private/chatrooms/setting/instructions/11111111-1111-4111-8111-111111111111
Content-Type: application/json
{
"text": "You are ACME's sales CRM analyst. Answer from Accounts, Contacts, Deals, and Activities. A null computed value means cannot be determined, never zero."
}3. 送出問題並輪詢同一則訊息
前端以歷程型 chat_type: ai 送出問題:
POST /private/chatrooms/chat/ai/11111111-1111-4111-8111-111111111111
Content-Type: application/json
{
"chat_type": "ai",
"message": "請依 Total Amount 由高到低,列出 Globex、Initech、Umbrella 的排名。"
}POST 可能先回傳 streaming 中的訊息。保留其 id,再輪詢:
GET /private/chatrooms/chat/ai/11111111-1111-4111-8111-111111111111?limit=10&chat_type=ai直到同一個 id 的 streaming_status 成為 complete。在這組資料中,答案應依序說明 Globex 750.0、Umbrella 330.0、Initech 200.0。
4. 用不同能力追問同一份資料
同一個 room 可以接著問:
{
"chat_type": "ai",
"message": "Globex 的 Last Activity 是哪一天?對應的活動主題是什麼?"
}分析員會先讀 Accounts 的 date-max rollup,再沿 Activities.Account 找到 2026-06-01 的 Contract signed。另一個問題「Amount 與 Probability 的 Pearson 相關係數是多少?」會忽略 Probability 為 null 的商機,使用有值的配對計算。
使用者會看到什麼
使用者留在原本的領域聊天室,用相同輸入框詢問「最高」、「加總」、「有哪些值」或「沿著關聯找到誰」。答案會帶出可核對的實體名稱與數字:例如 WMS 的 Acme Industrial 進貨量 130、PMS 的 Sprint 25 完成率 12.5%、Booking 的 Dr. Wang 使用率 50%、電商的門市 GMV 排名。需要寫入時,助理仍會走既有確認流程。
變化與下一步
- 要讓 agent 只載入被選取的系統,閱讀insight systems與grant/insight 指南。
- 要讓答案可追溯到 filter、aggregate 與 profile 行為,閱讀查詢資料列。
- 要讓不同身分得到正確切片,閱讀有效權限與欄位可見性。
試試看
先在 API Playground用 records.search 與 records.aggregate 算出一個可核對的數字,再到對應聊天室用自然語言詢問同一件事;建模階段可用 IaC 工作台載入任一 domain graph。