app:
  description: 地名に基づきWeb検索で最新情報を収集し、観光スポット・アクセス・飲食・イベント・季節・口コミなどを踏まえて出典付きの旅行記事を自動生成するワークフロー。
  icon: 🧳
  icon_background: '#FFEAD5'
  mode: workflow
  name: Web検索連携 旅行記事自動生成（地名指定）
  use_icon_as_answer_icon: false
kind: app
version: 0.1.5
workflow:
  conversation_variables: []
  environment_variables: []
  features:
    file_upload:
      image:
        enabled: false
    opening_statement: ''
    suggested_questions: []
    suggested_questions_after_answer:
      enabled: false
    text_to_speech:
      enabled: false
  graph:
    edges:
    - data:
        sourceType: start
        targetType: llm
      id: start-to-llm1
      source: start
      target: llm1
    - data:
        sourceType: llm
        targetType: code
      id: llm1-to-code1
      source: llm1
      target: code1
    - data:
        sourceType: code
        targetType: llm
      id: code1-to-llm2
      source: code1
      target: llm2
    - data:
        sourceType: llm
        targetType: end
      id: llm2-to-end
      source: llm2
      target: end
    nodes:
    - data:
        title: 開始
        type: start
        variables:
        - label: query
          max_length: 2048
          options: []
          required: true
          type: paragraph
          variable: query
      height: 90
      id: start
      position:
        x: 80
        y: 282
      type: custom
      width: 244
    - data:
        title: 地名と要望の理解・検索指示生成
        type: llm
        model:
          provider: openai
          name: gpt-5-mini
          mode: chat
          completion_params:
            temperature: 0.4
        prompt_template:
        - role: system
          text: "あなたは旅行メディア編集部のリサーチディレクターです。\nユーザーが入力したテキストから「主な目的地（地名）」と「旅行記事に盛り込みたい要望」を理解し、\n\
            その地名に対して行うべきWeb検索クエリを日本語で具体的に設計してください。\n\n出力フォーマット（厳守・JSON形式、キー名は変更しないこと）:\n\
            {\n  \"destination\": \"<主な目的地の地名。国・地域・都市・エリア名など。なければ空文字>\",\n  \"search_queries\"\
            : [\n    \"<観光スポット情報を得るための検索クエリ>\",\n    \"<アクセス・交通情報を得るための検索クエリ>\",\n\
            \    \"<飲食店・グルメ情報を得るための検索クエリ>\",\n    \"<イベント・季節情報を得るための検索クエリ>\",\n  \
            \  \"<口コミ・体験談を得るための検索クエリ>\"\n  ],\n  \"article_requirements\": \"<ユーザーの要望や対象読者、トーン、日数などの条件を要約した日本語文>\"\
            \n}\n\nポリシー:\n- 「destination」はユーザーの入力から最も中心となる地名を1つだけ抽出してください。\n- 「search_queries」はすべて\
            \ destination を含む自然な日本語の検索語句にしてください。\n- 口コミサイト名や旅行サイト名（例: Google マップ,\
            \ 食べログ, じゃらん など）を含めても構いません。\n- まだWeb検索は行わず、「どのような検索を行うべきか」だけを設計します。\n"
        - role: user
          text: 'ユーザー入力:

            {{#start.query#}}


            上記にもとづき、指定フォーマットのJSONだけを出力してください。

            '
        vision:
          enabled: false
        memory: null
        context:
          enabled: true
      height: 90
      id: llm1
      position:
        x: 380
        y: 282
      type: custom
      width: 244
    - data:
        title: JSONパース & Web検索実行
        type: code
        code_language: python3
        code: "import json\nimport requests\nimport os\nimport time\n\ndef _safe_str(v):\n\
          \    \"\"\"Convert any value to string safely.\"\"\"\n    if v is None:\n\
          \        return \"\"\n    if isinstance(v, list):\n        return \"\\n\"\
          .join(str(i) for i in v)\n    return str(v)\n\ndef _safe_list(v):\n    \"\
          \"\"Ensure value is a list.\"\"\"\n    if v is None:\n        return []\n\
          \    if isinstance(v, str):\n        return [v]\n    if isinstance(v, list):\n\
          \        return v\n    return [v]\n\ndef main(**kwargs) -> dict:\n    inputs\
          \ = kwargs\n    raw = inputs.get(\"llm1_output\", \"\")\n    # JSON部分の抽出（安全のため最初の{から最後の}までを切り出す）\n\
          \    start_idx = raw.find(\"{\")\n    end_idx = raw.rfind(\"}\")\n    if\
          \ start_idx == -1 or end_idx == -1 or end_idx <= start_idx:\n        return\
          \ dict(\n            destination=\"\",\n            article_requirements=\"\
          \",\n            aggregated_snippets=\"\",\n            sources=\"\"\n \
          \       )\n\n    json_str = raw[start_idx:end_idx+1]\n\n    try:\n     \
          \   data = json.loads(json_str)\n    except Exception:\n        return dict(\n\
          \            destination=\"\",\n            article_requirements=\"\",\n\
          \            aggregated_snippets=\"\",\n            sources=\"\"\n     \
          \   )\n\n    destination = str(data.get(\"destination\", \"\")).strip()\n\
          \    article_requirements = str(data.get(\"article_requirements\", \"\"\
          )).strip()\n    search_queries = data.get(\"search_queries\", [])\n\n  \
          \  if not isinstance(search_queries, list):\n        search_queries = []\n\
          \n    # Web検索APIの呼び出し設定\n    # 環境変数に API_ENDPOINT, API_KEY がある想定\n    #\
          \ 例: 独自のプロキシやSerper, Bing, Google Custom Search等\n    endpoint = os.environ.get(\"\
          WEB_SEARCH_ENDPOINT\", \"\")\n    api_key = os.environ.get(\"WEB_SEARCH_API_KEY\"\
          , \"\")\n\n    headers = dict()\n    if api_key:\n        headers[\"Authorization\"\
          ] = \"Bearer \" + api_key\n\n    all_snippets = []\n    all_sources = []\n\
          \n    # タイムアウトや失敗に備え、各クエリはベストエフォートで処理\n    for q in search_queries:\n  \
          \      q_str = str(q).strip()\n        if not q_str:\n            continue\n\
          \n        # シンプルな汎用JSON APIを想定\n        # GET {endpoint}?q=<query>&num=5\
          \ のような形式を想定して実装\n        try:\n            if not endpoint:\n          \
          \      # エンドポイント未設定の場合は検索をスキップし、ダミーメッセージだけ残す\n                snippet =\
          \ \"【注意】WEB_SEARCH_ENDPOINT が設定されていないため、このクエリは実検索されていません: \" + q_str\n \
          \               all_snippets.append(snippet)\n                all_sources.append(\"\
          No web search endpoint configured\")\n                continue\n\n     \
          \       params = dict(q=q_str, num=\"5\")\n            resp = requests.get(endpoint,\
          \ headers=headers, params=params, timeout=15)\n            if resp.status_code\
          \ != 200:\n                snippet = \"【注意】検索APIエラー (\" + str(resp.status_code)\
          \ + \"): \" + q_str\n                all_snippets.append(snippet)\n    \
          \            all_sources.append(\"Search API error \" + str(resp.status_code))\n\
          \                continue\n\n            try:\n                body = resp.json()\n\
          \            except Exception:\n                body = None\n\n        \
          \    # body からタイトル・スニペット・URLを抽出する\n            # フォーマットは\n            #\
          \ {\n            #   \"results\": [\n            #       {\"title\": \"\
          ...\", \"snippet\": \"...\", \"url\": \"...\"},\n            #       ...\n\
          \            #   ]\n            # }\n            # のような一般的な形を想定\n      \
          \      if not body or \"results\" not in body:\n                snippet\
          \ = \"【注意】検索結果の形式が想定外です: \" + q_str\n                all_snippets.append(snippet)\n\
          \                all_sources.append(\"Unexpected search result format\"\
          )\n                continue\n\n            results = body.get(\"results\"\
          , [])\n            for r in results:\n                title = str(r.get(\"\
          title\", \"\")).strip()\n                summary = str(r.get(\"snippet\"\
          , \"\")).strip()\n                url = str(r.get(\"url\", \"\")).strip()\n\
          \n                if not (title or summary or url):\n                  \
          \  continue\n\n                snippet_lines = []\n                if title:\n\
          \                    snippet_lines.append(\"タイトル: \" + title)\n        \
          \        if summary:\n                    snippet_lines.append(\"概要: \"\
          \ + summary)\n                if url:\n                    snippet_lines.append(\"\
          URL: \" + url)\n\n                if snippet_lines:\n                  \
          \  all_snippets.append(\"\\n\".join(snippet_lines))\n\n                if\
          \ url:\n                    all_sources.append(url)\n\n            # APIに負荷をかけすぎないよう、少し待機\n\
          \            time.sleep(0.5)\n        except Exception as e:\n         \
          \   # 例外が起きても全体は止めず、警告テキストだけ追加\n            msg = \"【注意】クエリ '\" + q_str\
          \ + \"' の検索中にエラー: \" + str(e)\n            all_snippets.append(msg)\n  \
          \          all_sources.append(\"Search error\")\n\n    # 重複URLを削除\n    unique_sources\
          \ = []\n    seen = set()\n    for s in all_sources:\n        if s in seen:\n\
          \            continue\n        seen.add(s)\n        unique_sources.append(s)\n\
          \n    aggregated_snippets = \"\\n\\n---\\n\\n\".join(all_snippets)\n   \
          \ sources_text = \"\\n\".join(unique_sources)\n\n    return dict(\n    \
          \    destination=destination,\n        article_requirements=article_requirements,\n\
          \        aggregated_snippets=aggregated_snippets,\n        sources=sources_text\n\
          \    )\n"
        variables:
        - value_selector:
          - llm1
          - text
          variable: llm1_output
        outputs:
          destination:
            type: string
            children: null
          article_requirements:
            type: string
            children: null
          aggregated_snippets:
            type: string
            children: null
          sources:
            type: string
            children: null
      height: 90
      id: code1
      position:
        x: 680
        y: 282
      type: custom
      width: 244
    - data:
        title: Web情報統合 旅行記事生成
        type: llm
        model:
          provider: openai
          name: gpt-5-mini
          mode: chat
          completion_params:
            temperature: 0.7
        prompt_template:
        - role: system
          text: "あなたは旅行メディア「プロ編集者兼ライター」です。\n以下の要件に従って、日本語で読みやすく魅力的な旅行記事を作成してください。\n\
            \n目的:\n- 指定された地名の「現地の魅力」が伝わる旅行記事を作成する。\n- Web検索で得た最新情報（観光スポット、アクセス、飲食店、イベント、季節情報、口コミなど）を統合する。\n\
            - 信頼性のため、参考にした情報源URLを最後に「参考情報・出典」として列挙する。\n\n記事構成の指示:\n1. 冒頭リード文\n   -\
            \ 行き先の雰囲気や魅力が直感的に伝わるような導入。\n   - 想定読者（ファミリー、一人旅、カップル、ビジネス出張ついで等）が分かる場合は言及する。\n\
            \n2. 基本情報・アクセス\n   - 行き先の簡単な説明（場所、エリアの特徴など）。\n   - 主要都市・空港・新幹線駅などからのアクセス方法（電車・バス・車など）をわかりやすく整理。\n\
            \n3. モデルコース（1日 or 2日程度を目安）\n   - 時系列で「朝〜昼〜夕方〜夜」といった流れで、立ち寄るスポットを提案。\n\
            \   - 各スポットごとに「見どころ」「所要時間の目安」「移動の一言メモ」を添える。\n\n4. おすすめ観光スポット・アクティビティ\n\
            \   - 定番スポットと、可能であれば少し穴場も混ぜる。\n   - 写真映え、歴史・文化、自然、アート・ショッピングなど、バランスよく紹介。\n\
            \   - 口コミで評価されているポイントが分かる場合は簡単に反映する。\n\n5. グルメ・カフェ・ナイトライフ\n   - ご当地グルメや人気の飲食店・カフェ・バーなどをジャンル別に。\n\
            \   - 代表的なメニューや雰囲気（カジュアル、高級、子連れ向き、ベジタリアン対応など）を記載。\n\n6. イベント・季節ごとの楽しみ方\n\
            \   - 年間を通じたイベント、祭り、シーズンごとのおすすめ（桜、紅葉、雪景色、海水浴など）。\n   - ベストシーズンや、閑散期の魅力が分かる場合は補足。\n\
            \n7. 旅行のコツ・注意点・便利情報\n   - 気候や服装の目安、混雑・行列の傾向、予約の必要性。\n   - 治安・マナー・チップ・公共交通の注意点など、実用的なアドバイス。\n\
            \   - 子連れ・高齢者連れ・一人旅など、特定の読者に有用な情報があれば追記。\n\n8. まとめ\n   - この記事で紹介した旅の魅力を簡潔に振り返る締めの一節。\n\
            \n出典・参考情報の表記:\n- 記事本文の最後に「参考情報・出典」という見出しをつけ、\n  利用した、または参考にした可能性のあるURLを箇条書きで列挙する。\n\
            - URLがダミーやエラー情報の場合は、その旨を一言そえてよい。\n\n文体・トーン:\n- 日本語で、丁寧すぎずカジュアルすぎない「旅行ガイド記事」くらいの標準的なトーン。\n\
            - 見出しには「## 見出し名」「### 小見出し名」を使うMarkdown形式。\n- 箇条書きリストを適宜活用して読みやすくする。\n\n\
            重要な制約:\n- Web検索結果はその時点の情報に基づくため、「最新の公式情報は各施設の公式サイト等で必ず確認してください」と注意書きを入れる。\n\
            - 検索スニペットから推測しすぎず、「推定」「〜とされています」など不確実さを示す表現も適宜用いる。\n- 存在しない施設名や事実をでっち上げないこと。\n\
            \n\n---\n\n【最終出力フォーマット (この指示が最優先)】\n最終出力は **読みやすい日本語の Markdown** で返してください。JSON\n\
            配列・オブジェクトをそのまま出力してはいけません。\n- 見出し (## / ###) でセクションを区切る\n- リスト (- / 1.)\
            \ や表 (| ... |) で構造化\n- 各項目の意図や根拠が分かるよう短い説明文を添える\n- 営業デモで顧客に直接見せられる体裁にする\n\
            \n(構造化されたデータ項目を内部で扱うのは構いませんが、出力時に Markdown\nに変換してください。)"
        - role: user
          text: '目的地（地名）:

            {{#code1.destination#}}


            ユーザーの要望・条件の要約:

            {{#code1.article_requirements#}}


            Web検索から得られた情報スニペット（要約・口コミ等を含む）:

            {{#code1.aggregated_snippets#}}


            利用した（または参考にした）情報源URL一覧:

            {{#code1.sources#}}


            上記の情報に基づき、「旅行メディアの記事」として完成度の高い旅行ガイド記事を作成してください。

            途中で本文を打ち切らず、全セクションを含む形で最後まで書いてください。

            '
        vision:
          enabled: false
        memory: null
        context:
          enabled: true
      height: 90
      id: llm2
      position:
        x: 980
        y: 282
      type: custom
      width: 244
    - data:
        title: 終了
        type: end
        outputs:
        - value_selector:
          - llm2
          - text
          variable: result
      height: 90
      id: end
      position:
        x: 1280
        y: 282
      type: custom
      width: 244
  hash: null
