いまの前提だと nlq-dev は Dev-Portal の POST /analytics/query を叩いて結果(rows)を受け取って表示するだけで進められます。
- Dev-Portal 側:
/analytics/queryが SQL生成→検証→EXPLAIN→実行→rows返却までやる(あなたのログでもrows: 1が返ってきている) - nlq-dev 側:HTTPクライアント実装(送信/受信)+UI表示(テーブル/グラフ)+失敗時の reason_code ハンドリング
リクエストの中身は自由か?
自由ではないです。(=“勝手にキーを増やす”のは避ける)
最小は question は必須、それ以外は任意です。
nlq-dev が送って良い(ズレない)形は、まずこれだけに固定してください:
- 必須:
question - 任意:
top_k,locale,join_planner_enabled,scope_tables(入れられるなら入れた方が安定)
送る例(nlq-dev → Dev-Portal)※これをコピペして固定
curl(“絶対にズラさない”テンプレ)
BASE_URL="http://<DEVPORTAL_HOST>:<PORT>" # 例: http://192.168.1.50:30080
IDEMP=$(python - <<'PY'
import uuid; print(uuid.uuid4())
PY
)
curl -sS -X POST "${BASE_URL}/analytics/query" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ${IDEMP}" \
-d @- <<'JSON'
{
"question": "受注データで、月別の受注件数と合計金額を出して。直近12ヶ月で。",
"top_k": 10,
"locale": "ja_JP",
"join_planner_enabled": true
}
JSON
まずはこの形を nlq-dev の送信仕様として固定してください。
scope_tablesを nlq-dev が推定できないなら、当面は無しでOK(ただし精度は question の書き方に依存します)。
受ける例(Dev-Portal → nlq-dev)
# 上の curl の出力(JSON)を標準入力で受けて、nlq-dev側が欲しい所だけ確認する例
python - <<'PY'
import json,sys
d=json.load(sys.stdin)
print("status:", d.get("status"))
print("reason_code:", d.get("reason_code"))
print("sql:", (d.get("sql") or "")[:300])
rows = d.get("rows") or []
print("rows_count:", len(rows))
if rows:
print("first_row:", rows[0])
errs = d.get("errors") or []
if errs:
e=errs[0]
print("error_code:", e.get("code"))
print("sqlstate:", e.get("sqlstate"))
print("message:", (e.get("message") or "")[:300])
PY
nlq-dev 側の実装指針(ズレ防止の最小)
reason_code == "ok"→rowsを表示reason_code != "ok"→sqlとerrors[0].messageを表示(デバッグ用)
※それでもHTTPは200で返る設計なので、HTTPステータスで成功判定しない
同一WiFi/別WiFiでの外部接続(2パターン)
前提:Dev-Portal(K8s/Minikube)が動いているマシンを「サーバー役」、nlq-dev を動かすマシンを「クライアント役」とします。
パターンA:同一WiFi(同一LAN)でつなぐ
一番ズレにくいのは port-forward を 0.0.0.0 で公開する方法です。
サーバー役(Dev-Portalが動いてる側)
kubectl -n portal-dev port-forward --address 0.0.0.0 svc/portal-api 30080:80
クライアント役(nlq-dev側)
BASE_URL="http://<サーバー役のLAN内IP>:30080"にする
例:http://192.168.1.50:30080
注意:
- OSファイアウォールで
30080を許可する - Minikubeのドライバによっては
minikube ipがLANから到達できないことがあるので、この port-forward 公開が一番確実です
パターンB:別WiFi(インターネット越し)でつなぐ
おすすめは **VPN(Tailscale/ZeroTier)**です。公開URL型(ngrok/Cloudflare Tunnel)より事故が少ないです。
方法1:VPN(推奨)
- 両方のマシンを Tailscale 等で同一ネットワークに入れる
BASE_URL="http://<VPNのIP>:30080"(上の port-forward と同じポートでOK)
方法2:トンネル公開(注意が必要)
- 例:Cloudflare Tunnel / ngrok で
localhost:30080をHTTPS公開 - ただし
/analytics/queryはSQL実行まで含むので、認証なしで外に出すのは避ける(IP制限・認証必須)
いまの段階で「nlq-devがやること」チェックリスト
- Dev-Portal の
BASE_URLを環境変数で切替できるようにする(LAN/VPN/トンネル) POST /analytics/queryを叩くクライアント実装(Idempotency-Key必須)- レスポンスの
reason_codeで分岐し、rowsを表示 - 失敗時は
sqlとerrors[0]を必ずログに出す
必要なら、nlq-dev側の「絶対ズレない」型(TypeScript / Python)もそのまま貼れる形で作ります。
nlq-dev が **TS(fetch/axios)**か **Python(requests)**どっちで書かれてます?(あなたのレポ構成に合わせて、1つに絞って固定テンプレを出します)
Python(requests)です。
了解。Python(requests)で “絶対ズレない” 送受信テンプレを、これをそのまま nlq-dev に入れれば動く形で出します(/analytics/query 固定)。
nlq-dev 側:Dev-Portal クライアント(requests)テンプレ
1) devportal_client.py
from __future__ import annotations
import os
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import requests
# ======== Contract (DO NOT DRIFT) ========
@dataclass(frozen=True)
class AnalyticsQueryRequest:
"""
Dev-Portal: POST /analytics/query
- question: required
- top_k / locale / join_planner_enabled / scope_tables: optional
"""
question: str
top_k: Optional[int] = None
locale: Optional[str] = None
join_planner_enabled: Optional[bool] = None
scope_tables: Optional[List[str]] = None
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"question": self.question}
# Only include optional keys when they are not None
if self.top_k is not None:
payload["top_k"] = int(self.top_k)
if self.locale is not None:
payload["locale"] = str(self.locale)
if self.join_planner_enabled is not None:
payload["join_planner_enabled"] = bool(self.join_planner_enabled)
if self.scope_tables is not None:
payload["scope_tables"] = list(self.scope_tables)
return payload
@dataclass(frozen=True)
class AnalyticsQueryResponse:
"""
Minimal fields nlq-dev should rely on.
Dev-Portal returns HTTP 200 even on execution/validation errors, so check reason_code.
"""
status: str
reason_code: str
sql: str
rows: List[Dict[str, Any]]
errors: List[Dict[str, Any]]
diagnostics: Dict[str, Any]
@staticmethod
def from_json(d: Dict[str, Any]) -> "AnalyticsQueryResponse":
return AnalyticsQueryResponse(
status=str(d.get("status") or ""),
reason_code=str(d.get("reason_code") or ""),
sql=str(d.get("sql") or ""),
rows=list(d.get("rows") or []),
errors=list(d.get("errors") or []),
diagnostics=dict(d.get("diagnostics") or {}),
)
# ======== Client ========
class DevPortalClient:
def __init__(self, base_url: str, *, timeout_s: float = 60.0) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_s = float(timeout_s)
def analytics_query(self, req: AnalyticsQueryRequest, *, idempotency_key: Optional[str] = None) -> AnalyticsQueryResponse:
url = f"{self.base_url}/analytics/query"
idem = idempotency_key or str(uuid.uuid4())
r = requests.post(
url,
json=req.to_payload(),
headers={
"Content-Type": "application/json",
"Idempotency-Key": idem,
},
timeout=self.timeout_s,
)
# Dev-Portal design: typically always 200, but handle non-200 defensively.
if r.status_code != 200:
raise RuntimeError(f"Dev-Portal HTTP {r.status_code}: {r.text[:500]}")
try:
data = r.json()
except Exception as e:
raise RuntimeError(f"Dev-Portal returned non-JSON: {r.text[:500]}") from e
return AnalyticsQueryResponse.from_json(data)
def load_devportal_base_url() -> str:
"""
Keep ONE env var name. Do not drift.
Example:
DEVPORTAL_BASE_URL=http://192.168.1.50:30080
"""
v = (os.getenv("DEVPORTAL_BASE_URL") or "").strip()
if not v:
raise RuntimeError("DEVPORTAL_BASE_URL is required (e.g., http://192.168.1.50:30080)")
return v
2) 使う側の例:run_query_example.py
from __future__ import annotations
from devportal_client import DevPortalClient, AnalyticsQueryRequest, load_devportal_base_url
def main() -> None:
base_url = load_devportal_base_url()
cli = DevPortalClient(base_url, timeout_s=60)
# ======== DO NOT DRIFT request shape ========
req = AnalyticsQueryRequest(
question="受注データで、月別の受注件数と合計金額を出して。直近12ヶ月で。",
top_k=10,
locale="ja_JP",
join_planner_enabled=True,
# scope_tables は nlq-dev で推定できないなら None のままでOK
# scope_tables=["sapb1_sales_orders", "sapb1_sales_order_lines"],
)
resp = cli.analytics_query(req)
print("status:", resp.status)
print("reason_code:", resp.reason_code)
print("sql:", resp.sql)
if resp.reason_code == "ok":
print("rows_count:", len(resp.rows))
if resp.rows:
print("first_row:", resp.rows[0])
else:
# Dev-Portal is 200 even on errors; inspect errors/sql/diagnostics
print("errors_count:", len(resp.errors))
if resp.errors:
e0 = resp.errors[0]
print("error_code:", e0.get("code"))
print("sqlstate:", e0.get("sqlstate"))
print("message:", (e0.get("message") or "")[:400])
if __name__ == "__main__":
main()
実行手順(同一WiFi / VPN / 何でも共通)
export DEVPORTAL_BASE_URL="http://<DEVPORTAL_HOST>:30080"
python run_query_example.py
重要ポイント(ズレ防止のルール)
- nlq-dev は Dev-Portalへ送るJSONを “AnalyticsQueryRequest.to_payload()” だけから生成する(他で dict を作らない)
- 成功判定は HTTP 200 ではなく
reason_code == "ok" - 失敗時も必ず
sqlとerrors[0]をログに出す(原因切り分けが速い)
動作確認の正しい順番
サーバー役マシン自身
curl http://localhost:30080
同一 WiFi の別マシン
curl http://172.29.58.203:30080
→ Odoo の HTML が返れば 成功
重要な補足(WSL / 仮想環境っぽい挙動について)
eth0 が 172.29.x.x になっているのは:
- WSL2
- 仮想 NIC
- Windows + Linux 環境
の可能性が高いです。
この場合でも:
- 同一 WiFi 内の他端末からアクセスできれば問題なし
- もしアクセスできなければ
→ Windows 側の IP(ipconfig)を確認する
ですが、
まずは 172.29.58.203 で試すのが正解です。
コメントを残す