目次
ガイド

プログラムを書く

ActQLのプログラムの書き方。Pipe構文のGoogleSQL、LLMを呼ぶINFER、ファイルとコンテンツ、書き戻すEffect。

このガイドは、ActQLのプログラムを書くすべての人、つまり人とコーディングエージェントの両方に向けたものです。普通のクエリから、ファイルを読み、LLMに聞き、書き戻し、Connectorの操作を実行するプログラムまでを順に説明し、最後に、プログラムを検査可能で安全に保つための習慣をまとめます。Program、Run、Effect、Checkpointという言葉に馴染みがなければ、先にコンセプトを読んでください。

プログラムはSQLです

プログラムはGoogleSQLで書きます。そこに、SQLで表現できない処理のためのActQL独自の演算が加わります。LLMに聞く、ベクトルを検索する、ファイルを読む、書き戻す、などの操作です。SQLを知っていれば、ActQLの大部分はもう知っています。

SELECT c.account_id, COUNT(*) AS open_cases
FROM support.cases AS c
WHERE c.status = 'open'
GROUP BY c.account_id
ORDER BY open_cases DESC
LIMIT 10;

テーブルの名前は <connector>.<resource> です。support.cases は、support という名前の接続が持つ cases というリソースを指します。接続が何を公開するかはそのConnectorが決め、actql catalog show support で一覧できます。

パラメーターは @name と書き、プログラムの実行時に渡します(CLIでは --params、ライブラリでは parameters)。

SELECT id, name FROM crm.accounts WHERE owner = @owner;

ActQLはGoogleSQLのサブセットを提供しています。使える範囲はSQL互換性に正確に書いてあり、その外の構文は check が名前付きの理由で拒否します。

Pipe構文

GoogleSQLのPipe構文は、同じクエリをステップの並びとして書くものです。FROM(または完全な SELECT)から始め、以降のステップはそれぞれ |> で始めます。各ステップは、前のステップのテーブルを受け取ります。

FROM support.cases AS c
|> WHERE c.status = 'open'
|> AGGREGATE COUNT(*) AS open_cases GROUP BY c.account_id
|> ORDER BY open_cases DESC
|> LIMIT 10

下の表の WHERE、SELECT、EXTEND、AGGREGATE、JOIN、ORDER BY、LIMIT はGoogleSQL自身のPipe演算子で、RESULT、INFER、NEAREST、SERIALIZE、ASSERT UNIQUE BY、EFFECT がActQLの追加分です。どちらも、人に説明するのと同じ上から下への順番で書きます。普通の SELECT のあとなら、どこからでもPipeに切り替えられます。

ステップの一覧:

ステップすること
|> WHERE condcond が真のレコードだけを残す
|> SELECT expr AS name, …列を、並べた式に置き換える
|> EXTEND expr AS name, …既存の列を残したまま列を足す
|> AGGREGATE agg AS name, … GROUP BY colsグループ化して集計する(COUNT、SUM、AVG、MIN、MAX、ARRAY_AGG など)
|> JOIN <table> AS t ON …別のテーブルをJOINする(LEFT JOIN、INNER JOIN など)
|> ORDER BY colsレコードを並べ替える
|> LIMIT n [OFFSET m]先頭のレコードだけを残す
|> RESULT nameここまでのテーブルを名前付きのResultとして公開し、続ける
|> INFER …各レコードについてLLMに聞く
|> NEAREST …各レコードに近いベクトルを探す
|> SERIALIZE …テーブルを1つのファイルのコンテンツにする
|> ASSERT UNIQUE BY (cols)列が一意でなければRunを失敗させる
|> EFFECT …書き込みや操作を宣言する。常に最後のステップ

PIVOT と、GoogleSQLのウィンドウ関数(OVER、QUALIFY)も使えます。完全な一覧はSQL互換性にあります。

FROM には、Pipeを使わずにJOINを続けて書けます:FROM a JOIN b ON … |> WHERE …。最初の |> より後ろでは、JOINはそれぞれ独立した |> JOIN ステップになります。

途中のテーブルに名前を付ける

WITH name AS (…) でクエリに名前を付けると、後ろのステップからその名前で参照できます。名前を付けるだけでは、何も読まず、何も公開しません。テーブルに名前が付くだけです。

WITH open_cases AS (
  FROM support.cases |> WHERE status = 'open'
)
SELECT account_id, COUNT(*) AS n
FROM open_cases
GROUP BY account_id;

|> RESULT name は別物です。これは、そのテーブルを名前付きのResultとして公開し、Runのあとに呼び出し元が読めるようにします(actql result run_… --result name、execution.result('name'))。読み取りだけのプログラムは、RESULT を書かなければ最終的なテーブルを main というResultとして公開します。Effectで終わるプログラムは、RESULT を書かない限り何も公開しません。何を書き込んだかを呼び出し元に見せたいときは、RESULT を足してください。

ファイルを扱う

ファイルは2つのステップで読みます。この2つはあえて分けてあります。一覧は何があるかを教え、内容の読み取りは特定のバージョンに対する明示的な読み取りです。LIST_FILES は、ファイルごとに resource、version、path を持つレコードを返します。メタデータだけで、何もダウンロードしません。内容は <connector>.contents という別の読み取り元にあり、resource と version でJOINします。LIST_FILES には一覧の読み取り元(gcs.objects、drive.files、local.files)を指定します。一覧が1つだけの接続なら、接続名だけでもかまいません。

FROM LIST_FILES(gcs.objects, glob => r'invoices/*.pdf') AS f
|> JOIN gcs.contents AS c ON f.resource = c.resource AND f.version = c.version
|> SELECT f.path, f.media_type, c.content AS document
|> RESULT invoices;
  • JOINでバージョンを指定するので、Runの途中で置き換えられたファイルが、同じパスの別の文書として紛れ込むことはありません。
  • content は content_ref です。取り込んだバイト列への、メディアタイプ付きの型付き参照であって、文字列ではありません。読み取りはプログラムのバイト数の上限で抑えられ、丸ごと読めないファイルは拒否されます。途中で切られることはありません。
  • model taskがそのメディアタイプのコンテンツのフィールドを宣言していれば、content_ref を INFER の入力にできます(LLMとINFER)。

これが、非構造データをプログラムに取り込む方法です。Google Drive、S3、GCSにある文書、PDF、スプレッドシート、画像が、型付きのコンテンツ参照を持つレコードになります。それを INFER に渡すと、LLMの答えが型付きの列として返ってきます。請求書ごとの仕入先と合計金額、契約書ごとの1行の要約、添付ファイルごとの分類といったものです。あとは、CRMやDWHのデータとJOINし、ほかの結果と同じように書き戻せます。

FROM LIST_FILES(gcs.objects, glob => r'invoices/*.pdf') AS f
|> JOIN gcs.contents AS c ON f.resource = c.resource AND f.version = c.version
|> INFER `model-task:invoice-fields.v1` INPUT (document = c.content) AS inv
|> WHERE inv.outcome = 'ok'
|> SELECT f.path, inv.value.supplier, inv.value.total, inv.value.due_date;

ファイルそのものがテーブルである場合(CSV、Parquet、JSON Lines、Iceberg)は、ローカルファイルのように、それらをテーブルとして宣言するConnectorを通して普通のテーブルとして読みます。FROM local.orders が返すのは型付きのレコードで、バイト列ではありません。

SERIALIZE はその逆です。現在のテーブルを、1つのファイルのコンテンツにします。

FROM crm.accounts
|> SELECT id, name
|> SERIALIZE FORMAT CSV ORDER BY id
     OPTIONS (name = 'accounts.csv', header = TRUE, null_marker = '')
|> RESULT export;

結果は、name、content、media_type、size_bytes、row_count を持つ1レコードです。SERIALIZE はコンテンツを作るだけで、何もアップロードしません。どこかに保存するのはEffectの仕事です(書き戻す)。永続的なテーブルを作るには CREATE TABLE か CREATE OR REPLACE TABLE を使います。

LLMに聞く

INFER は、各レコードをLLMに渡して、型の付いた答えを足します。LLM、その指示、入力と出力の型は、バージョン付きのmodel taskとして一度だけ定義します(LLMとINFER)。プログラムはそれを名前で参照します。

FROM salesforce.accounts AS a
JOIN snowflake.usage_30d AS u ON a.id = u.account_id
|> INFER `model-task:churn-risk.v1`
     INPUT (notes = a.description, active_users = u.active_users, trend = u.trend) AS risk
|> WHERE risk.outcome = 'ok' AND risk.value.level = 'high'
  • INPUT (…) は、taskの入力フィールドを列や式に対応付けます。この値だけがRunの外に出ます。フィールドと同じ名前の列なら、名前だけで書けます:INPUT (notes, active_users, trend)。
  • AS risk で答えに名前を付けます。risk.outcome は ok か、failed:timeout のような失敗です。risk.value には型付きの出力(risk.value.level、risk.value.reason)が入り、失敗したときはNULLです。
  • 入力の列はレコードに残るので、そのまま絞り込みやJOINに使えます。一部だけ残したいときは KEEP (cols) を足します。
  • 各レコードにはキーが必要です。LLMに項目ごとに1回だけ聞き、答えを対応付けるためです。ITEM KEY を省けるのは、証明できる一意キーがちょうど1つあるときだけです。キーのあるテーブルを FROM で読んだ場合や、上の例のように、そのキーが一意のまま残るJOINの後がこれにあたります。それ以外のJOINや集計の後は、ITEM KEY (col) でキーを指定し、check が一意性を証明できなければ INFER の前に ASSERT UNIQUE BY (col) を足してください。

outcome は必ず確認してください。LLMが答えられなかったレコードもほかと一緒に戻ってくるので、それをどうするかはプログラムが決めます。答えだけが必要なところでは outcome = 'ok' で絞り込み、誰かが確認したり再処理したりすべきところでは、失敗したレコードを RESULT として残します。部分的な結果を拒否したいときは、ON FAILURE FAIL を足します。有限回の再試行のあとに1件でも非成功のレコードが残れば、ステップの結果は公開されず、Effectには何も届かず、すでに行った呼び出しは記録として残ります。

ベクトルとグラフを検索する

NEAREST は、各レコードについて、別のテーブルの中でベクトルがもっとも近い K 件を見つけます。

SELECT c.id, c.description AS question, c.embedding AS query_vec
FROM salesforce.cases AS c
WHERE c.priority = 'urgent'
|> NEAREST kb.articles USING (query_vec) K 3 AS hit
|> AGGREGATE ARRAY_AGG(hit.body ORDER BY hit.rank) AS refs
     GROUP BY id, question
|> INFER `model-task:draft-reply.v1`
     INPUT (question, refs) AS draft

hit には hit.score、hit.rank と、候補のテーブルに body 列があれば hit.body が入ります。クエリ側の列はレコードに残り、候補側のほかの列は KEEP (articles.col, …) で残せます。検索はメトリック(既定は cosine)のもとで厳密です。INFER と組み合わせれば、1つのプログラムの中でRAG(検索拡張生成)になります。

グラフ、ベクトル、イベント列には、それぞれ型付きのCarrierがあります。グラフはテーブルから GRAPH (…) で組み立て、GQLの流儀の MATCH パターンで問い合わせます。イベント列には固有の名前付き演算(セッション、ウィンドウ、時間的な相関)があります。MATCH、NEAREST、イベントの演算から出てくるのはまたテーブルなので、ほかのテーブルと同じようにJOINや絞り込みができます。各演算と引数は構文リファレンスにあります。

書き戻す

プログラムが外部システムを変える手段は、Effectだけです。Effectはテーブルのステップのあとに宣言し、常に最後に置きます。何かを送る前にポリシーを通り、ポリシーが求めるときは承認を通ります。

EFFECT の形では、対象、キーの列、設定するフィールドを書きます。

|> EFFECT UPDATE salesforce.Account
     ID id SET (
       Churn_Risk__c = risk.value.level,
       Risk_Reason__c = risk.value.reason
     );

見慣れたSQLの形も、同じEffectです。

UPDATE salesforce.Account
SET Churn_Risk__c = 'high'
WHERE Id = @account_id;

UPDATE のほかに、レコードには INSERT、UPSERT、DELETE で書き込み、コンテンツやレコードは STORE で保存します。

FROM support.cases
|> WHERE status = 'open'
|> SELECT id, subject, opened_at
|> SERIALIZE FORMAT CSV ORDER BY opened_at
     OPTIONS (name = 'open-cases.csv', header = TRUE, null_marker = '')
|> EFFECT STORE s3.objects SET (key = 'exports/' || name, content = content);

STORE は、現在のレコードの content を受け取ってオブジェクトやファイルとして書きます。content は SERIALIZE から来ることも、ファイルをある場所から別の場所へ動かすプログラムでは contents の読み取り元から来ることもあります。テーブルのリソースに向ければ、レコードそのものを保存します。プレビューには、保存先、コンテンツそのもの、既存のオブジェクトを置き換えてよいかが固定されます。何を保存でき、どのフィールドを受け取るかは、各Connectorのページにあります。

Effectの値は、現在のレコードに対する式です。何かを送る前に評価され、評価された値がプレビューに出て、承認の対象になります。Pipeの終端に置くデータを変えるEffectは1つです。読んで、判断して、1つのことを書くプログラムが、レビューと再開の単位になります。同じレコードについて通知もしたいときは、RESULT で公開し、; の後ろにCommandの後置を通知ごとに足します。それらとEffectは1つのEffect Setになり、1つのCheckpointで承認されます(構文リファレンス)。順番に実行しなければならないステップは、ProgramモデルのStageです。

操作を実行する

変更のすべてがレコードの書き込みではありません。メールを送る、メッセージを投稿する、Tableauの抽出を更新する、ジョブを開始する、といったものはConnectorが宣言する操作で、プログラムからは EFFECT COMMAND で実行します。

FROM support.replies AS r
|> WHERE r.status = 'ready'
|> EFFECT COMMAND gmail.send
     ID r.id
     INPUT (to = r.recipient, subject = r.subject, body = r.body);

gmail.send は自由なHTTP呼び出しではありません。Connectorが公開し、Hostが認可した、バージョン付きのCommand契約1つに解決されます。契約が、入力フィールドとその型、操作の対象となるリソース、必要な権限、プレビューに出る内容、結果の確認方法を決めます。check は、Runの前にそのすべてを検査します。

  • ID はレコードのキーです。プレビュー、status、復旧で各操作を識別するもので、宛先やメールボックスではありません。
  • TARGET expr は、Connectorがレコードごとに対象を必要とするとき(ワークブック、スレッド、フォルダなど)に、操作の対象となるリソースを指定します。gmail.send は接続のメールボックスに対する操作なので、TARGET はありません。
  • プレビューには、各操作が何をするか(ここでは宛先、件名、本文のすべて)が、何かを送る前に並びます。
  • 操作の結果は、システムが要求を受け付ければ accepted、結果を確認できれば applied、応答が失われれば unknown です。受け付けられたメールが、相手に届いたとは限りません。不明な結果はConnectorを通して確認し、推測で再送することはありません。

SERIALIZE の後に STORE、読み取りの後に COMMAND。これがプログラムの合成のしかたです。コンテンツは明示的な1ステップで作られ、それを保存または送信するEffectに渡されます。

上限とガード

どのプログラムも、レコード数、バイト数、LLMの呼び出し回数、Effectの上限のもとで動きます。大半はHostとConnectorが決めます。自分でも追加でき、それをガードと呼びます。JOIN の MAX_OUTPUT_ROWS n は出力を、INFER の MAX ITEMS n は送る項目数を抑えます。ガードは LIMIT とは違います。LIMIT 10 は10件が欲しいという指定で、10件が返ります。ガードは処理が超えてはならない天井で、超えればRunはエラーで止まります。黙って切り詰められることはなく、途中までの結果が完全なものとして返ることもありません。

拒否されるもの

ActQLには、ユーザー定義関数、ループ、インラインのコード、生のHTTP呼び出しはありません。対応していないSQLは、check の時点で名前付きの理由(checkの理由)とともに拒否されます。近似で通すことはありません。言語で表現できないことが必要なら、答えはConnectorの操作かmodel taskであって、クエリの中の回避策ではありません。

利用時のTips

人にもエージェントにも当てはまります。

  1. run の前に check。 業務データを読まず、プロバイダーも呼ばずに、型と名前のエラーを行番号付きで見つけます。
  2. 最初の本番実行の前に explain。 何を読むか、何がプッシュダウンされるか、どの選択がRunに持ち越されるか、どこで承認が必要になり得るかが分かります。
  3. INPUT は最小限に。 LLMに届くのはその列だけです。入力が小さいほど安く、速く、レビューしやすくなります。
  4. value を読む前に outcome を確認し、失敗したレコードをどうするか決めます。
  5. データを変えるEffectはプログラムに1つ、最後に。 読んで、判断して、1つのことを書くプログラムは、レビューも再開も簡単です。その内容の通知は、Resultに対するCommandの後置であって、2つ目の書き込みではありません。
  6. 対象を識別できるようにする。 キー、リソース、バージョン、Command契約など、プレビューに出せて復旧時にシステムと照合できる対象を持つ書き込みや操作を選びます。
  7. ポリシーはHostに置き、プログラムには置かない。 プログラムが自分に権限を与えることはできません。承認のルールをSQLに書こうとしないでください。

機械向けの入力

プログラムは、JSONまたはYAMLの文書として渡すこともできます。SQLが変換される正規のモデルです。プログラムを生成するエージェントやツールには便利かもしれませんが、人が必要とすることはほとんどありません。Programモデルがこれを定義し、actql check --json はどちらの形式にも対応します。

次に読む

Source: actql/actql/docs/authoring.md at d904416674e4 · Edit on GitHub