/** * WPML compatibility functions * * @global array $duplicated_posts Array to store the posts being duplicated. * * @package Yoast\WP\Duplicate_Post * @since 3.2 */ add_action( 'admin_init', 'duplicate_post_wpml_init' ); /** * Add handlers for WPML compatibility. */ function duplicate_post_wpml_init() { if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { add_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'shutdown', 'duplicate_wpml_string_packages', 11 ); } } global $duplicated_posts; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: Renaming a global variable is a BC break. $duplicated_posts = []; /** * Copy post translations. * * @global SitePress $sitepress Instance of the Main WPML class. * @global array $duplicated_posts Array of duplicated posts. * * @param int $post_id ID of the copy. * @param WP_Post $post Original post object. * @param string $status Status of the new post. */ function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' ) { global $sitepress; global $duplicated_posts; remove_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10 ); remove_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10 ); $current_language = $sitepress->get_current_language(); $trid = $sitepress->get_element_trid( $post->ID ); if ( ! empty( $trid ) ) { $translations = $sitepress->get_element_translations( $trid ); $new_trid = $sitepress->get_element_trid( $post_id ); foreach ( $translations as $code => $details ) { if ( $code !== $current_language ) { if ( $details->element_id ) { $translation = get_post( $details->element_id ); if ( ! $translation ) { continue; } $new_post_id = duplicate_post_create_duplicate( $translation, $status ); if ( ! is_wp_error( $new_post_id ) ) { $sitepress->set_element_language_details( $new_post_id, 'post_' . $translation->post_type, $new_trid, $code, $current_language ); } } } } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: see above. $duplicated_posts[ $post->ID ] = $post_id; } } /** * Duplicate string packages. * * @global array() $duplicated_posts Array of duplicated posts. */ function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: renaming the function would be a BC-break. global $duplicated_posts; foreach ( $duplicated_posts as $original_post_id => $duplicate_post_id ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $original_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $original_post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $new_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $duplicate_post_id ); if ( is_array( $original_string_packages ) ) { foreach ( $original_string_packages as $original_string_package ) { $translated_original_strings = $original_string_package->get_translated_strings( [] ); foreach ( $new_string_packages as $new_string_package ) { $cache = new WPML_WP_Cache( 'WPML_Package' ); $cache->flush_group_cache(); $new_strings = $new_string_package->get_package_strings(); foreach ( $new_strings as $new_string ) { if ( isset( $translated_original_strings[ $new_string->name ] ) ) { foreach ( $translated_original_strings[ $new_string->name ] as $language => $translated_string ) { do_action( // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. 'wpml_add_string_translation', $new_string->id, $language, $translated_string['value'], $translated_string['status'] ); } } } } } } } } portal-devのつなぎ方 – Raqqa

portal-devのつなぎ方

いまの前提だと nlq-dev は Dev-Portal の POST /analytics/query を叩いて結果(rows)を受け取って表示するだけで進められます。

  • Dev-Portal 側:/analytics/querySQL生成→検証→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"sqlerrors[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 を表示
  • 失敗時は sqlerrors[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"
  • 失敗時も必ず sqlerrors[0] をログに出す(原因切り分けが速い)

動作確認の正しい順番

サーバー役マシン自身

curl http://localhost:30080

同一 WiFi の別マシン

curl http://172.29.58.203:30080

→ Odoo の HTML が返れば 成功


重要な補足(WSL / 仮想環境っぽい挙動について)

eth0172.29.x.x になっているのは:

  • WSL2
  • 仮想 NIC
  • Windows + Linux 環境

の可能性が高いです。

この場合でも:

  • 同一 WiFi 内の他端末からアクセスできれば問題なし
  • もしアクセスできなければ
    → Windows 側の IP(ipconfig)を確認する

ですが、
まずは 172.29.58.203 で試すのが正解です。


Comments

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です