Download app.py from Ke-san/EventX: direct link, hf CLI and curl.
- Browser
- Download file 16.8 kB
-
https://huggingface.co/spaces/Ke-san/EventX/resolve/main/app.py
- Command line
-
hf download hf://spaces/Ke-san/EventX/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Ke-san/EventX/resolve/main/app.py
16.8 kB
| import os | |
| import json | |
| import math | |
| from typing import Any, Dict, List, Tuple | |
| import gradio as gr | |
| # OpenAI SDK(v1系) | |
| try: | |
| from openai import OpenAI | |
| except Exception: | |
| OpenAI = None | |
| # ---------------------------- | |
| # 設定 | |
| # ---------------------------- | |
| DEFAULT_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") # 必要ならSpacesのEnvで差し替え | |
| DB_PATH = os.getenv("EVENTS_DB_PATH", "events_db.json") | |
| # ---------------------------- | |
| # プロンプト(prompts.py があれば優先) | |
| # ---------------------------- | |
| PROMPT_PRE_DEFAULT = """\ | |
| あなたはイベント運営の専門コンサルです。 | |
| ユーザーのイベント要件と、参照用の類似イベント情報を根拠として、 | |
| 「従来より失敗しにくい設計案」を短時間で作るための提案を作成してください。 | |
| # ユーザー要件 | |
| {requirements_json} | |
| # 類似イベント(参照データ) | |
| {similar_events_json} | |
| # 出力要件(JSONのみ) | |
| 次のJSONスキーマに厳密に従って出力してください。余計な文章は禁止です。 | |
| {{ | |
| "risks": [ | |
| {{"title": "失敗しやすいポイント(短い)", "why": "理由(具体的)", "mitigation": "対策(具体的)"}} | |
| ], | |
| "budget_plan": [ | |
| {{"item": "費目", "amount_yen": 0, "reason": "根拠(類似イベント/要件に基づく)"}} | |
| ], | |
| "schedule": [ | |
| {{"phase": "フェーズ名", "when": "時期/当日時間帯", "owner": "担当(例: 主催/警備/受付)", "notes": "注意点"}} | |
| ], | |
| "assumptions": [ | |
| {{"key": "前提", "value": "値"}} | |
| ] | |
| }} | |
| 制約: | |
| - risksは最低5件 | |
| - budget_planは最低6費目(人件費/警備/設備/広報/予備費/その他など) | |
| - scheduleは「準備(数週間前〜前日)」「当日(時間帯)」「撤収」の流れがわかるように最低10行 | |
| - amount_yenは整数 | |
| """ | |
| PROMPT_POST_DEFAULT = """\ | |
| あなたはイベント運営の専門コンサルです。 | |
| 事前計画と事後実績の差分から、改善点と成果物(成果レポート/引継ぎ資料)を作成してください。 | |
| # 事前計画(JSON) | |
| {pre_plan_json} | |
| # 事後実績(ユーザー入力) | |
| {post_actual_json} | |
| # 出力要件(JSONのみ) | |
| 次のJSONスキーマに厳密に従って出力してください。余計な文章は禁止です。 | |
| {{ | |
| "diff": [ | |
| {{"kpi": "指標名", "plan": "計画値/想定", "actual": "実績", "impact": "影響(短く)"}} | |
| ], | |
| "improvements": [ | |
| {{"issue": "課題", "cause": "原因", "next_action": "次回アクション(具体)"}} | |
| ], | |
| "report": {{ | |
| "summary": "200〜400字で要約", | |
| "highlights": ["良かった点1", "良かった点2", "良かった点3"], | |
| "numbers": [ | |
| {{"name": "来場者数", "value": "xxx"}}, | |
| {{"name": "ピーク時間帯", "value": "xx:xx-xx:xx"}} | |
| ], | |
| "risks_observed": ["当日発生したリスク1", "リスク2"] | |
| }}, | |
| "handover": {{ | |
| "checklist": ["次回チェック項目1", "2", "3", "4", "5"], | |
| "next_time": ["次回こうする1", "2", "3"] | |
| }} | |
| }} | |
| 制約: | |
| - diffは最低4件 | |
| - improvementsは最低5件 | |
| - report.highlightsは最低3件 | |
| - handover.checklistは最低7件 | |
| """ | |
| try: | |
| # 同一ディレクトリに prompts.py がある場合は優先 | |
| from prompts import PROMPT_PRE as PROMPT_PRE_FILE, PROMPT_POST as PROMPT_POST_FILE # type: ignore | |
| PROMPT_PRE = PROMPT_PRE_FILE | |
| PROMPT_POST = PROMPT_POST_FILE | |
| except Exception: | |
| PROMPT_PRE = PROMPT_PRE_DEFAULT | |
| PROMPT_POST = PROMPT_POST_DEFAULT | |
| # ---------------------------- | |
| # ユーティリティ | |
| # ---------------------------- | |
| def load_db() -> List[Dict[str, Any]]: | |
| with open(DB_PATH, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| def safe_json(obj: Any) -> str: | |
| return json.dumps(obj, ensure_ascii=False, indent=2) | |
| def score_similarity(req: Dict[str, Any], ev: Dict[str, Any]) -> float: | |
| """ | |
| 最短用:ルール+簡易スコア | |
| """ | |
| score = 0.0 | |
| # 一致系 | |
| if req["venue_type"] == ev.get("venue_type"): | |
| score += 3.0 | |
| if req["event_type"] == ev.get("event_type"): | |
| score += 3.0 | |
| # 来場者差(小さいほど加点) | |
| rv = req["visitors"] | |
| evv = ev.get("visitors", 0) | |
| if rv and evv: | |
| diff = abs(rv - evv) | |
| score += max(0.0, 5.0 - (diff / 500.0)) # 500人差で-1点相当 | |
| # 予算差(小さいほど加点) | |
| rb = req["budget_yen"] | |
| eb = ev.get("budget_yen", 0) | |
| if rb and eb: | |
| diff = abs(rb - eb) | |
| score += max(0.0, 5.0 - (diff / 200000.0)) # 20万円差で-1点相当 | |
| # 目的(重なり) | |
| goals = set(req.get("goals", [])) | |
| ev_goals = set(ev.get("goals", [])) | |
| if goals and ev_goals: | |
| score += 0.8 * len(goals.intersection(ev_goals)) | |
| return score | |
| def top_k_similar(req: Dict[str, Any], db: List[Dict[str, Any]], k: int = 3) -> List[Dict[str, Any]]: | |
| scored = [(score_similarity(req, ev), ev) for ev in db] | |
| scored.sort(key=lambda x: x[0], reverse=True) | |
| return [ev for _, ev in scored[:k]] | |
| def resolve_api_key(api_key_ui: str = "") -> str: | |
| """ | |
| 1) Secrets(OPENAI_API_KEY)優先 | |
| 2) 空ならUI入力を使う | |
| """ | |
| k = (os.getenv("OPENAI_API_KEY", "") or "").strip() | |
| if k: | |
| return k | |
| return (api_key_ui or "").strip() | |
| def call_openai_json(prompt: str, api_key_ui: str = "") -> Tuple[bool, str]: | |
| """ | |
| OpenAIに投げて「JSON文字列」を返す(失敗時はテキスト) | |
| """ | |
| api_key = resolve_api_key(api_key_ui=api_key_ui) | |
| if not api_key: | |
| return False, "OPENAI_API_KEY が設定されていません。SpacesのSecretsに追加するか、画面上のAPIキー欄に入力してください。" | |
| if OpenAI is None: | |
| return False, "openai パッケージが見つかりません。requirements.txt を確認してください。" | |
| client = OpenAI(api_key=api_key) | |
| # Responses API優先。失敗したらchat.completionsにフォールバック | |
| try: | |
| resp = client.responses.create( | |
| model=DEFAULT_MODEL, | |
| input=prompt, | |
| ) | |
| text = resp.output_text | |
| return True, text | |
| except Exception: | |
| try: | |
| resp = client.chat.completions.create( | |
| model=DEFAULT_MODEL, | |
| messages=[ | |
| {"role": "system", "content": "You must output ONLY valid JSON."}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| ) | |
| text = resp.choices[0].message.content | |
| return True, text | |
| except Exception as e: | |
| return False, f"OpenAI呼び出しでエラー: {e}" | |
| def extract_json(text: str) -> Tuple[bool, Any]: | |
| """ | |
| モデルが余計な文章を混ぜても、最初の{...}を拾ってJSONとして解釈する簡易処理 | |
| """ | |
| if not text: | |
| return False, None | |
| t = text.strip() | |
| # すでにJSONっぽい場合 | |
| try: | |
| return True, json.loads(t) | |
| except Exception: | |
| pass | |
| # 先頭の { から最後の } までを抜く | |
| start = t.find("{") | |
| end = t.rfind("}") | |
| if start >= 0 and end > start: | |
| candidate = t[start : end + 1] | |
| try: | |
| return True, json.loads(candidate) | |
| except Exception: | |
| return False, candidate | |
| return False, t | |
| def yen_from_range(budget_range: str) -> int: | |
| mapping = { | |
| "〜50万円": 500000, | |
| "〜100万円": 1000000, | |
| "〜200万円": 2000000, | |
| "〜300万円": 3000000, | |
| "〜500万円": 5000000, | |
| "500万円以上": 7000000, | |
| } | |
| return mapping.get(budget_range, 1000000) | |
| # ---------------------------- | |
| # 事前:生成 | |
| # ---------------------------- | |
| def pre_generate(event_type, venue_type, visitors_range, budget_range, goals, constraints_text, api_key_ui, state: Dict[str, Any]): | |
| db = load_db() | |
| # 来場者レンジ → 代表値 | |
| vr_map = { | |
| "〜300人": 300, | |
| "〜1,000人": 1000, | |
| "〜3,000人": 3000, | |
| "〜5,000人": 5000, | |
| "〜10,000人": 10000, | |
| "10,000人以上": 15000, | |
| } | |
| visitors = vr_map.get(visitors_range, 1000) | |
| budget_yen = yen_from_range(budget_range) | |
| req = { | |
| "event_type": event_type, | |
| "venue_type": venue_type, | |
| "visitors": visitors, | |
| "budget_yen": budget_yen, | |
| "goals": goals or [], | |
| "constraints": constraints_text.strip() if constraints_text else "", | |
| } | |
| similars = top_k_similar(req, db, k=3) | |
| prompt = PROMPT_PRE.format( | |
| requirements_json=safe_json(req), | |
| similar_events_json=safe_json(similars), | |
| ) | |
| ok, text = call_openai_json(prompt, api_key_ui=api_key_ui) | |
| if not ok: | |
| return safe_json(similars), "", f"エラー: {text}", state | |
| ok2, parsed = extract_json(text) | |
| if not ok2: | |
| # JSONとして読めない場合は生のテキストを返す | |
| plan_json_str = text | |
| plan_view = f"JSONとして解釈できませんでした。生出力を表示します。\n\n{plan_json_str}" | |
| else: | |
| plan_json_str = safe_json(parsed) | |
| plan_view = plan_json_str | |
| # stateに保持(同一セッション内で事後に渡す) | |
| state = state or {} | |
| state["pre_requirements"] = req | |
| state["pre_similars"] = similars | |
| state["pre_plan_raw"] = plan_json_str | |
| return safe_json(similars), plan_json_str, plan_view, state | |
| # ---------------------------- | |
| # 事後:比較&生成 | |
| # ---------------------------- | |
| def post_generate(actual_visitors, peak_time, troubles, satisfaction, notes, pre_plan_json_text, api_key_ui, state: Dict[str, Any]): | |
| # 事前計画:優先順 | |
| # 1) ユーザーがテキストに貼ったJSON | |
| # 2) stateに保存された最新の事前計画 | |
| pre_plan_text = (pre_plan_json_text or "").strip() | |
| if not pre_plan_text: | |
| pre_plan_text = (state or {}).get("pre_plan_raw", "").strip() | |
| if not pre_plan_text: | |
| return "", "事前計画がありません。先に「事前」タブで生成するか、事前計画JSONを貼り付けてください。", state | |
| # 事後実績 | |
| post_actual = { | |
| "actual_visitors": int(actual_visitors) if actual_visitors else None, | |
| "peak_time": peak_time.strip() if peak_time else "", | |
| "troubles": troubles or [], | |
| "satisfaction_1to5": satisfaction, | |
| "notes": (notes or "").strip(), | |
| } | |
| prompt = PROMPT_POST.format( | |
| pre_plan_json=pre_plan_text, | |
| post_actual_json=safe_json(post_actual), | |
| ) | |
| ok, text = call_openai_json(prompt, api_key_ui=api_key_ui) | |
| if not ok: | |
| return "", f"エラー: {text}", state | |
| ok2, parsed = extract_json(text) | |
| if not ok2: | |
| out = f"JSONとして解釈できませんでした。生出力を表示します。\n\n{text}" | |
| return "", out, state | |
| out_json = safe_json(parsed) | |
| return out_json, out_json, state | |
| # ---------------------------- | |
| # UI | |
| # ---------------------------- | |
| with gr.Blocks(title="EventX 最短デモ(事前/事後)") as demo: | |
| gr.Markdown( | |
| "## EventX 最短デモ(事前/事後 切り替え)\n" | |
| "- 可能ならAPIキーはSpacesのSecrets(OPENAI_API_KEY)に設定してください。\n" | |
| "- **フォールバック**:Secretsが効かない場合のみ、下の欄にAPIキーを入力して動かせます(公開Spaceでは注意)。" | |
| ) | |
| # フォールバック用:UIでAPIキーを入力(値は表示されない) | |
| api_key_input = gr.Textbox( | |
| label="OpenAI API Key(フォールバック用)", | |
| type="password", | |
| placeholder="sk-...(Secretsが効かないときだけ使用)", | |
| ) | |
| state = gr.State({}) | |
| with gr.Tabs(): | |
| # ---------------------------- | |
| # 事前タブ | |
| # ---------------------------- | |
| with gr.TabItem("事前:設計案生成"): | |
| gr.Markdown("### 1) 要件入力 → 類似イベント → 失敗ポイント/予算/スケジュール生成") | |
| with gr.Row(): | |
| event_type = gr.Dropdown( | |
| label="イベント種別", | |
| choices=["マルシェ", "学園祭", "地域祭り", "フードフェス", "展示会", "スポーツ大会", "その他"], | |
| value="マルシェ", | |
| ) | |
| venue_type = gr.Dropdown( | |
| label="場所タイプ", | |
| choices=["屋外", "屋内", "複数拠点", "駐車場分散"], | |
| value="屋外", | |
| ) | |
| with gr.Row(): | |
| visitors_range = gr.Dropdown( | |
| label="想定来場者数", | |
| choices=["〜300人", "〜1,000人", "〜3,000人", "〜5,000人", "〜10,000人", "10,000人以上"], | |
| value="〜1,000人", | |
| ) | |
| budget_range = gr.Dropdown( | |
| label="予算レンジ", | |
| choices=["〜50万円", "〜100万円", "〜200万円", "〜300万円", "〜500万円", "500万円以上"], | |
| value="〜100万円", | |
| ) | |
| goals = gr.CheckboxGroup( | |
| label="目的(複数選択)", | |
| choices=["集客", "売上", "地域PR", "安全重視", "回遊促進", "満足度向上"], | |
| value=["集客", "安全重視"], | |
| ) | |
| constraints_text = gr.Textbox( | |
| label="制約・前提(任意)", | |
| placeholder="例:雨天時は体育館へ移動、音出しNG、駐車場200台、受付は1箇所…", | |
| lines=3, | |
| ) | |
| run_pre = gr.Button("設計案を生成", variant="primary") | |
| gr.Markdown("### 類似イベント(上位3件)") | |
| similar_view = gr.Code(language="json") | |
| gr.Markdown("### 事前計画JSON(事後タブにそのまま貼れます)") | |
| pre_plan_json_text = gr.Textbox(lines=8, label="事前計画JSON(保存用)") | |
| gr.Markdown("### 生成結果(表示用)") | |
| pre_plan_view = gr.Code(language="json") | |
| run_pre.click( | |
| fn=pre_generate, | |
| inputs=[event_type, venue_type, visitors_range, budget_range, goals, constraints_text, api_key_input, state], | |
| outputs=[similar_view, pre_plan_json_text, pre_plan_view, state], | |
| ) | |
| # ---------------------------- | |
| # 事後タブ | |
| # ---------------------------- | |
| with gr.TabItem("事後:振り返り・改善"): | |
| gr.Markdown("### 2) 実績入力 → 計画との差分 → 改善点 → 成果レポート/引継ぎ") | |
| gr.Markdown("#### 事前計画JSON(空なら、事前タブで生成した最新を自動で使います)") | |
| pre_plan_paste = gr.Textbox( | |
| lines=6, | |
| label="事前計画JSON(貼り付け任意)", | |
| placeholder="事前タブのJSONを貼ると、別セッションでも動きます", | |
| ) | |
| with gr.Row(): | |
| actual_visitors = gr.Number(label="実来場者数(任意)", value=850, precision=0) | |
| peak_time = gr.Textbox(label="ピーク時間帯(例 12:00-13:00)", value="12:00-13:00") | |
| troubles = gr.CheckboxGroup( | |
| label="発生トラブル(複数選択)", | |
| choices=[ | |
| "受付混雑", | |
| "駐車場待ち", | |
| "行列過多", | |
| "雨天対応", | |
| "迷子/案内不足", | |
| "回線不調", | |
| "備品不足", | |
| "スタッフ不足", | |
| "クレーム対応", | |
| ], | |
| value=["受付混雑"], | |
| ) | |
| satisfaction = gr.Slider(label="主観評価(1〜5)", minimum=1, maximum=5, step=1, value=3) | |
| notes = gr.Textbox(label="自由メモ(任意)", lines=3, placeholder="例:12時に急に人が増えた、誘導が足りなかった、など") | |
| run_post = gr.Button("差分&レポートを生成", variant="primary") | |
| gr.Markdown("### 生成結果(JSON)") | |
| post_out_json = gr.Code(language="json") | |
| post_msg = gr.Textbox(label="メッセージ", lines=2) | |
| run_post.click( | |
| fn=post_generate, | |
| inputs=[actual_visitors, peak_time, troubles, satisfaction, notes, pre_plan_paste, api_key_input, state], | |
| outputs=[post_out_json, post_msg, state], | |
| ) | |
| demo.launch() | |