Corin1998 commited on
Commit
18c1b76
·
verified ·
1 Parent(s): 1c047d5

Update ui.py

Browse files
Files changed (1) hide show
  1. ui.py +176 -140
ui.py CHANGED
@@ -1,14 +1,12 @@
1
  from __future__ import annotations
2
 
3
- # === Writable config dirs (must come right after future import) ===
4
  import os
5
  os.environ.setdefault("APP_DATA_DIR", "/data/app_data" if os.access("/data", os.W_OK) else "/tmp/app_data")
6
  os.environ.setdefault("MPLCONFIGDIR", os.path.join(os.environ["APP_DATA_DIR"], "mplconfig"))
7
  os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)
8
- # =================================================================
9
 
10
- import json
11
- import uuid
12
  from datetime import datetime
13
  from typing import Dict
14
 
@@ -17,41 +15,35 @@ import pandas as pd
17
 
18
  from app.storage import (
19
  init_db, insert_variant, upsert_campaign, get_variant, get_metrics,
20
- get_campaign_value_per_conversion, log_event
 
 
 
21
  )
22
  from app.bandit import ThompsonBandit
 
23
  from app.forecast import SeasonalityModel
24
  from app.compliance import rule_based_check, llm_check_and_fix
25
  from app.openai_client import openai_chat_json
26
-
27
 
28
  # 初期化
29
  init_db()
30
  _seasonality_cache: Dict[str, SeasonalityModel] = {}
31
 
32
- # 固定カラム(常にこの形で返す)
33
  GENERATE_COLUMNS = ["variant_id", "status", "rejection_reason", "text"]
34
  REPORT_COLUMNS = ["variant_id","impressions","clicks","conversions","ctr","cvr","expected_value"]
35
 
36
- # JSONモード前提の厳格プロンプト
37
  GEN_SYSTEM = """
38
  あなたは日本語広告コピーのプロフェッショナルコピーライターです。
39
  出力は**次のJSONオブジェクトのみ**で厳密に返してください。余計な文章・説明・前置きは禁止です。
40
-
41
  形式:
42
- {
43
- "variants": [
44
- {"headline": "全角15-25字程度", "body": "全角40-90字程度"},
45
- ...
46
- ]
47
- }
48
-
49
  ルール:
50
  - 医薬効能の断定、100%、永久、即効、根拠のない数値などの誇大表現は禁止
51
  - CTAは自然に
52
  - 日本語で、句読点や記号は自然に
53
  """
54
-
55
  GEN_USER_TEMPLATE = """
56
  ブランド: {brand}
57
  商品/サービス: {product}
@@ -62,24 +54,20 @@ GEN_USER_TEMPLATE = """
62
 
63
  要件:
64
  - "variants" 配列の要素数は **ちょうど {k}** 件にしてください
65
- - 各要素は {{"headline": "...", "body": "..."}} のみ
66
  """
67
 
68
-
69
  def _seasonal(campaign_id: str) -> SeasonalityModel:
70
  if campaign_id not in _seasonality_cache:
71
  m = SeasonalityModel(campaign_id)
72
  try:
73
  m.fit()
74
  except Exception:
75
- # 失敗してもフォールバックあり
76
  pass
77
  _seasonality_cache[campaign_id] = m
78
  return _seasonality_cache[campaign_id]
79
 
80
-
81
  def _safe_get_variants(data, k: int):
82
- """LLM応答から variants 配列を安全に取り出して正規化。失敗時は None を返す。"""
83
  items = []
84
  if isinstance(data, dict) and isinstance(data.get("variants"), list):
85
  items = data["variants"]
@@ -95,16 +83,8 @@ def _safe_get_variants(data, k: int):
95
  })
96
  return out
97
 
98
-
99
  def _local_variants(brand: str, product: str, k: int):
100
- """LLMが不調でも UI ための最終フォルバック生成(簡易・無害表現)。"""
101
- base_head = [
102
- "使いやすさで選ばれています",
103
- "日々の習慣をシンプルに",
104
- "はじめてでも安心",
105
- "続けやすいサポートを",
106
- "いま必要な機能だけを"
107
- ]
108
  base_body = [
109
  "{brand}の「{product}」。生活になじむ設計で、今日からムリなく始められます。まずは詳細をご覧ください。",
110
  "毎日を少しラクに。{brand}の{product}が、あなたの習慣づくりを後押しします。今すぐチェック。",
@@ -114,128 +94,145 @@ def _local_variants(brand: str, product: str, k: int):
114
  ]
115
  out = []
116
  for i in range(k):
117
- hi = base_head[i % len(base_head)]
118
- bo = base_body[i % len(base_body)].format(brand=brand, product=product)
119
- out.append({"headline": hi, "body": bo})
120
  return out
121
 
122
-
123
  async def ui_generate(campaign_id: str, brand: str, product: str, target: str, tone: str, k_variants: int,
124
  ng_words: str, value_per_conversion: float):
125
- k_variants = int(k_variants) # Slider の float を明示的に int 化
126
  constraints = {"ng_words": [w.strip() for w in ng_words.splitlines() if w.strip()]} if ng_words else {}
127
-
128
- upsert_campaign(
129
- campaign_id, brand, product, target, tone, "ja", constraints, value_per_conversion
130
- )
131
 
132
  user = GEN_USER_TEMPLATE.format(
133
- brand=brand,
134
- product=product,
135
- target=target,
136
- tone=tone,
137
- constraints=json.dumps(constraints, ensure_ascii=False),
138
- k=k_variants,
139
  )
140
 
141
- # まずは通常プロンプトで JSON モード呼び出し
142
  items = None
143
  try:
144
  data = await openai_chat_json(
145
- [
146
- {"role": "system", "content": GEN_SYSTEM},
147
- {"role": "user", "content": user},
148
- ],
149
- temperature=0.2,
150
- max_tokens=1200,
151
  )
152
  items = _safe_get_variants(data, k_variants)
153
  except Exception:
154
  items = None
155
-
156
- # 失敗/空のときは、温度をさらに下げて再試行(より厳格に)
157
  if not items:
158
  try:
159
  retry_user = user + "\n\n注意: 'variants' は必ず指定件数、各要素は {\"headline\":\"...\",\"body\":\"...\"} のみ。"
160
  data = await openai_chat_json(
161
- [
162
- {"role": "system", "content": GEN_SYSTEM},
163
- {"role": "user", "content": retry_user},
164
- ],
165
- temperature=0.1,
166
- max_tokens=1000,
167
  )
168
  items = _safe_get_variants(data, k_variants)
169
  except Exception:
170
  items = None
171
-
172
- # それでも無理ならローカル生成(UIを止めない)
173
  if not items:
174
  items = _local_variants(brand, product, k_variants)
175
 
176
  rows = []
177
  for it in items[:k_variants]:
178
- headline = it["headline"]
179
- body = it["body"]
180
  text = f"{headline}\n{body}".strip()
181
  vid = str(uuid.uuid4())[:8]
182
 
183
  ok_rule, bads = rule_based_check(text, (constraints or {}).get("ng_words"))
184
- rejection_reason = None
185
- status = "approved"
186
-
187
- if not ok_rule:
188
- ok_llm, reasons, fixed = llm_check_and_fix(text)
189
- if ok_llm:
190
- text = fixed or text
191
- else:
192
- status = "rejected"
193
- rejection_reason = "; ".join(bads + reasons)
194
- else:
195
- ok_llm, reasons, fixed = llm_check_and_fix(text)
196
- if not ok_llm:
197
- text = fixed or text
198
-
199
- insert_variant(campaign_id, vid, text, status, rejection_reason)
200
- rows.append({
201
- "variant_id": vid,
202
- "status": status,
203
- "rejection_reason": rejection_reason or "",
204
- "text": text,
205
- })
206
 
207
- # 常に固定カラムで返す(空でもカラムを持つDataFrame)
208
  df = pd.DataFrame(rows, columns=GENERATE_COLUMNS)
209
  return df
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
- def ui_serve(campaign_id: str, hour: int, segment: str):
213
- ctx = {"hour": int(hour), "segment": (segment or "").strip() or None}
214
  m = _seasonal(campaign_id)
215
- bandit = ThompsonBandit(campaign_id)
216
- vid, _ = bandit.sample_arm(ctx, m.expected_ctr)
217
- if not vid:
 
218
  raise gr.Error("配信可能なバリアントがありません。まずは Generate してください。")
219
 
220
- row = get_variant(campaign_id, vid)
221
- if not row:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  raise gr.Error("バリアントが見つかりません。")
223
 
224
- # impression 記録
225
  log_event(campaign_id, vid, "impression", datetime.utcnow().isoformat(), None)
226
  ThompsonBandit.update_with_event(campaign_id, vid, "impression")
227
-
228
  return vid, row["text"]
229
 
230
-
231
- def ui_feedback(campaign_id: str, variant_id: str, event_type: str):
232
  if not variant_id:
233
  raise gr.Error("先に Serve してください。")
 
234
  log_event(campaign_id, variant_id, event_type, datetime.utcnow().isoformat(), None)
235
  ThompsonBandit.update_with_event(campaign_id, variant_id, event_type)
 
 
 
 
236
  return f"{event_type} を記録しました。"
237
 
238
-
239
  def ui_report(campaign_id: str):
240
  mets = get_metrics(campaign_id)
241
  vpc = get_campaign_value_per_conversion(campaign_id)
@@ -247,34 +244,44 @@ def ui_report(campaign_id: str):
247
  ev = ctr * cvr * vpc
248
  rows.append({
249
  "variant_id": r["variant_id"],
250
- "impressions": imp,
251
- "clicks": clk,
252
- "conversions": conv,
253
- "ctr": round(ctr, 4),
254
- "cvr": round(cvr, 4),
255
- "expected_value": round(ev, 6),
256
  })
257
- # 常に固定カラムで返す(空でもカラムを持つDataFrame
258
- df = pd.DataFrame(rows, columns=REPORT_COLUMNS)
259
- return df
260
-
261
-
262
- def ui_check(text: str):
263
- ok_rule, bads = rule_based_check(text, [])
264
- ok_llm, reasons, fixed = llm_check_and_fix(text)
265
- status = "pass" if (ok_rule and ok_llm) else "needs_fix"
266
- fixed_text = fixed or (text if status == "pass" else "")
267
- reasons_joined = "; ".join(bads + reasons)
268
- return status, reasons_joined, fixed_text
269
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
  with gr.Blocks(title="AdCopy MAB Optimizer", fill_height=True) as demo:
272
  gr.Markdown("""
273
- # AdCopy MAB Optimizer(HF UI)
274
- **広告コピー自動生成 Thompson Sampling(CTR×CVR) レポート** を、Hugging Face Spaces 上で完結。
275
- - LLM: OpenAI (`OPENAI_API_KEY` を Space Secrets に設定)
276
- - DB: SQLite(`/data/app_data/data.db` など、書き込み可能ディレクトリ
277
- - 季節性: Prophet/NeuralProphet(なければ簡易ヒューリスティック)
278
  """)
279
 
280
  with gr.Tab("1) Generate"):
@@ -291,36 +298,65 @@ with gr.Blocks(title="AdCopy MAB Optimizer", fill_height=True) as demo:
291
  table_gen = gr.Dataframe(headers=GENERATE_COLUMNS, interactive=False)
292
  btn_gen.click(ui_generate, [campaign_id, brand, product, target, tone, k_variants, ng_words, value_per_conv], [table_gen])
293
 
294
- with gr.Tab("2) Serve & Feedback"):
 
 
 
 
 
 
 
 
 
 
 
 
295
  with gr.Row():
296
  campaign_id2 = gr.Textbox(label="campaign_id", value="cmp-demo", scale=1)
297
  hour = gr.Slider(0, 23, value=20, step=1, label="hour")
298
  segment = gr.Textbox(label="segment (任意)")
 
299
  btn_serve = gr.Button("Serve Ad(impressionを記録)")
300
  served_vid = gr.Textbox(label="served variant_id", interactive=False)
301
  served_text = gr.Textbox(label="served text", lines=6, interactive=False)
302
- btn_serve.click(ui_serve, [campaign_id2, hour, segment], [served_vid, served_text])
303
 
304
  with gr.Row():
305
  btn_click = gr.Button("Clickを記録")
306
  btn_conv = gr.Button("Conversionを記録")
307
  msg = gr.Markdown()
308
- btn_click.click(lambda cid, vid: ui_feedback(cid, vid, "click"), [campaign_id2, served_vid], [msg])
309
- btn_conv.click(lambda cid, vid: ui_feedback(cid, vid, "conversion"), [campaign_id2, served_vid], [msg])
 
 
 
 
 
 
 
310
 
311
- with gr.Tab("3) Report"):
312
  campaign_id3 = gr.Textbox(label="campaign_id", value="cmp-demo")
313
- btn_rep = gr.Button("更新")
314
- table_rep = gr.Dataframe(headers=REPORT_COLUMNS, interactive=False)
315
- btn_rep.click(ui_report, [campaign_id3], [table_rep])
316
-
317
- with gr.Tab("4) Compliance Check"):
318
- cand = gr.Textbox(label="チェックする文面", lines=5)
319
- btn_chk = gr.Button("判定")
320
- status = gr.Textbox(label="status")
321
- reasons = gr.Textbox(label="reasons")
322
- fixed = gr.Textbox(label="fixed (修正案)", lines=5)
323
- btn_chk.click(ui_check, [cand], [status, reasons, fixed])
 
 
 
 
 
 
 
 
 
324
 
325
  if __name__ == "__main__":
326
  demo.queue().launch(server_name="0.0.0.0", server_port=7860)
 
1
  from __future__ import annotations
2
 
3
+ # === Writable config dirs ===
4
  import os
5
  os.environ.setdefault("APP_DATA_DIR", "/data/app_data" if os.access("/data", os.W_OK) else "/tmp/app_data")
6
  os.environ.setdefault("MPLCONFIGDIR", os.path.join(os.environ["APP_DATA_DIR"], "mplconfig"))
7
  os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)
 
8
 
9
+ import json, uuid, random
 
10
  from datetime import datetime
11
  from typing import Dict
12
 
 
15
 
16
  from app.storage import (
17
  init_db, insert_variant, upsert_campaign, get_variant, get_metrics,
18
+ get_campaign, set_campaign_settings,
19
+ get_campaign_value_per_conversion, log_event,
20
+ export_csv, reset_all, evaluate_stop_rules,
21
+ record_compliance_log, audit, get_variants
22
  )
23
  from app.bandit import ThompsonBandit
24
+ from app.linucb import LinUCB
25
  from app.forecast import SeasonalityModel
26
  from app.compliance import rule_based_check, llm_check_and_fix
27
  from app.openai_client import openai_chat_json
28
+ from app.adapters import XAdapter, MetaAdapter, GoogleAdsAdapter
29
 
30
  # 初期化
31
  init_db()
32
  _seasonality_cache: Dict[str, SeasonalityModel] = {}
33
 
 
34
  GENERATE_COLUMNS = ["variant_id", "status", "rejection_reason", "text"]
35
  REPORT_COLUMNS = ["variant_id","impressions","clicks","conversions","ctr","cvr","expected_value"]
36
 
 
37
  GEN_SYSTEM = """
38
  あなたは日本語広告コピーのプロフェッショナルコピーライターです。
39
  出力は**次のJSONオブジェクトのみ**で厳密に返してください。余計な文章・説明・前置きは禁止です。
 
40
  形式:
41
+ {"variants":[{"headline":"全角15-25字程度","body":"全角40-90字程度"}, ...]}
 
 
 
 
 
 
42
  ルール:
43
  - 医薬効能の断定、100%、永久、即効、根拠のない数値などの誇大表現は禁止
44
  - CTAは自然に
45
  - 日本語で、句読点や記号は自然に
46
  """
 
47
  GEN_USER_TEMPLATE = """
48
  ブランド: {brand}
49
  商品/サービス: {product}
 
54
 
55
  要件:
56
  - "variants" 配列の要素数は **ちょうど {k}** 件にしてください
57
+ - 各要素は {{"headline":"...","body":"..."}} のみ
58
  """
59
 
 
60
  def _seasonal(campaign_id: str) -> SeasonalityModel:
61
  if campaign_id not in _seasonality_cache:
62
  m = SeasonalityModel(campaign_id)
63
  try:
64
  m.fit()
65
  except Exception:
 
66
  pass
67
  _seasonality_cache[campaign_id] = m
68
  return _seasonality_cache[campaign_id]
69
 
 
70
  def _safe_get_variants(data, k: int):
 
71
  items = []
72
  if isinstance(data, dict) and isinstance(data.get("variants"), list):
73
  items = data["variants"]
 
83
  })
84
  return out
85
 
 
86
  def _local_variants(brand: str, product: str, k: int):
87
+ base_head = ["使いやすさで選ばれています","日々の習慣シンプルに","はじてでも安心","続けやすサポトを","いま必要な機能だけを"]
 
 
 
 
 
 
 
88
  base_body = [
89
  "{brand}の「{product}」。生活になじむ設計で、今日からムリなく始められます。まずは詳細をご覧ください。",
90
  "毎日を少しラクに。{brand}の{product}が、あなたの習慣づくりを後押しします。今すぐチェック。",
 
94
  ]
95
  out = []
96
  for i in range(k):
97
+ out.append({"headline": base_head[i % len(base_head)],
98
+ "body": base_body[i % len(base_body)].format(brand=brand, product=product)})
 
99
  return out
100
 
 
101
  async def ui_generate(campaign_id: str, brand: str, product: str, target: str, tone: str, k_variants: int,
102
  ng_words: str, value_per_conversion: float):
103
+ k_variants = int(k_variants)
104
  constraints = {"ng_words": [w.strip() for w in ng_words.splitlines() if w.strip()]} if ng_words else {}
105
+ upsert_campaign(campaign_id, brand, product, target, tone, "ja", constraints, value_per_conversion)
 
 
 
106
 
107
  user = GEN_USER_TEMPLATE.format(
108
+ brand=brand, product=product, target=target, tone=tone,
109
+ constraints=json.dumps(constraints, ensure_ascii=False), k=k_variants
 
 
 
 
110
  )
111
 
 
112
  items = None
113
  try:
114
  data = await openai_chat_json(
115
+ [{"role": "system", "content": GEN_SYSTEM},{"role": "user", "content": user}],
116
+ temperature=0.2, max_tokens=1200,
 
 
 
 
117
  )
118
  items = _safe_get_variants(data, k_variants)
119
  except Exception:
120
  items = None
 
 
121
  if not items:
122
  try:
123
  retry_user = user + "\n\n注意: 'variants' は必ず指定件数、各要素は {\"headline\":\"...\",\"body\":\"...\"} のみ。"
124
  data = await openai_chat_json(
125
+ [{"role": "system", "content": GEN_SYSTEM},{"role": "user", "content": retry_user}],
126
+ temperature=0.1, max_tokens=1000,
 
 
 
 
127
  )
128
  items = _safe_get_variants(data, k_variants)
129
  except Exception:
130
  items = None
 
 
131
  if not items:
132
  items = _local_variants(brand, product, k_variants)
133
 
134
  rows = []
135
  for it in items[:k_variants]:
136
+ headline, body = it["headline"], it["body"]
 
137
  text = f"{headline}\n{body}".strip()
138
  vid = str(uuid.uuid4())[:8]
139
 
140
  ok_rule, bads = rule_based_check(text, (constraints or {}).get("ng_words"))
141
+ status, rejection = "approved", None
142
+ ok_llm, reasons, fixed = llm_check_and_fix(text)
143
+ if not ok_rule and not ok_llm:
144
+ status, rejection = "rejected", "; ".join(bads + reasons)
145
+ elif not ok_rule and ok_llm:
146
+ text = fixed or text
147
+ elif ok_rule and not ok_llm:
148
+ text = fixed or text
149
+
150
+ insert_variant(campaign_id, vid, text, status, rejection)
151
+ record_compliance_log(campaign_id, vid, status, bads, ok_llm, reasons, fixed)
152
+
153
+ rows.append({"variant_id": vid, "status": status, "rejection_reason": rejection or "", "text": text})
 
 
 
 
 
 
 
 
 
154
 
 
155
  df = pd.DataFrame(rows, columns=GENERATE_COLUMNS)
156
  return df
157
 
158
+ def _policy_of(campaign_id: str) -> str:
159
+ cfg = get_campaign(campaign_id)
160
+ return str(cfg["policy"] or "thompson") if cfg else "thompson"
161
+
162
+ def _holdout_ratio_of(campaign_id: str) -> float:
163
+ cfg = get_campaign(campaign_id)
164
+ return float(cfg["holdout_ratio"] or 0.0) if cfg else 0.0
165
+
166
+ def _context(hour: int, segment: str) -> Dict[str, str | int | None]:
167
+ return {"hour": int(hour), "segment": (segment or "").strip() or None}
168
+
169
+ def ui_set_settings(campaign_id: str, policy: str, holdout: float, stop_min_impr: int, stop_rel_ev: float):
170
+ set_campaign_settings(campaign_id, policy, holdout, stop_min_impr, stop_rel_ev)
171
+ return f"Updated: policy={policy}, holdout={holdout}, stop_min_impressions={stop_min_impr}, stop_rel_ev_threshold={stop_rel_ev}"
172
+
173
+ def _uniform_variant(campaign_id: str) -> str | None:
174
+ vs = get_variants(campaign_id)
175
+ if not vs: return None
176
+ return random.choice(vs)["variant_id"]
177
 
178
+ def ui_serve(campaign_id: str, hour: int, segment: str, aa_min_impr: int):
179
+ ctx = _context(hour, segment)
180
  m = _seasonal(campaign_id)
181
+ policy = _policy_of(campaign_id)
182
+ holdout = _holdout_ratio_of(campaign_id)
183
+ mets = get_metrics(campaign_id)
184
+ if not mets:
185
  raise gr.Error("配信可能なバリアントがありません。まずは Generate してください。")
186
 
187
+ # A/Aテスト:各バリアントのimpressionsがしきい値未満なら一様ランダム
188
+ if aa_min_impr and aa_min_impr > 0:
189
+ for r in mets:
190
+ if int(r["impressions"]) < int(aa_min_impr):
191
+ vid = _uniform_variant(campaign_id)
192
+ row = get_variant(campaign_id, vid)
193
+ log_event(campaign_id, vid, "impression", datetime.utcnow().isoformat(), None)
194
+ ThompsonBandit.update_with_event(campaign_id, vid, "impression")
195
+ audit(campaign_id, "serve", {"variant_id": vid, "policy": "AA"})
196
+ return vid, row["text"]
197
+
198
+ # ホールドアウト:一定確率で一様ランダム
199
+ if holdout > 0 and random.random() < float(holdout):
200
+ vid = _uniform_variant(campaign_id)
201
+ row = get_variant(campaign_id, vid)
202
+ log_event(campaign_id, vid, "impression", datetime.utcnow().isoformat(), None)
203
+ ThompsonBandit.update_with_event(campaign_id, vid, "impression")
204
+ audit(campaign_id, "serve", {"variant_id": vid, "policy": "holdout"})
205
+ return vid, row["text"]
206
+
207
+ # 通常ポリシー
208
+ if policy == "linucb":
209
+ bandit = LinUCB(campaign_id)
210
+ vid, _ = bandit.choose(ctx)
211
+ else:
212
+ bandit = ThompsonBandit(campaign_id)
213
+ vid, _ = bandit.sample_arm(ctx, m.expected_ctr)
214
+
215
+ if not vid:
216
  raise gr.Error("バリアントが見つかりません。")
217
 
218
+ row = get_variant(campaign_id, vid)
219
  log_event(campaign_id, vid, "impression", datetime.utcnow().isoformat(), None)
220
  ThompsonBandit.update_with_event(campaign_id, vid, "impression")
221
+ audit(campaign_id, "serve", {"variant_id": vid, "policy": policy})
222
  return vid, row["text"]
223
 
224
+ def ui_feedback(campaign_id: str, variant_id: str, event_type: str, hour: int, segment: str):
 
225
  if not variant_id:
226
  raise gr.Error("先に Serve してください。")
227
+ ctx = _context(hour, segment)
228
  log_event(campaign_id, variant_id, event_type, datetime.utcnow().isoformat(), None)
229
  ThompsonBandit.update_with_event(campaign_id, variant_id, event_type)
230
+ # LinUCBはclickのみで学習(CTRモデル)
231
+ if event_type == "click" and _policy_of(campaign_id) == "linucb":
232
+ LinUCB(campaign_id).update_click(variant_id, ctx, reward=1.0)
233
+ audit(campaign_id, "feedback", {"variant_id": variant_id, "event": event_type})
234
  return f"{event_type} を記録しました。"
235
 
 
236
  def ui_report(campaign_id: str):
237
  mets = get_metrics(campaign_id)
238
  vpc = get_campaign_value_per_conversion(campaign_id)
 
244
  ev = ctr * cvr * vpc
245
  rows.append({
246
  "variant_id": r["variant_id"],
247
+ "impressions": imp, "clicks": clk, "conversions": conv,
248
+ "ctr": round(ctr, 4), "cvr": round(cvr, 4), "expected_value": round(ev, 6),
 
 
 
 
249
  })
250
+ return pd.DataFrame(rows, columns=REPORT_COLUMNS)
251
+
252
+ def ui_apply_stop(campaign_id: str):
253
+ paused = evaluate_stop_rules(campaign_id)
254
+ if not paused:
255
+ return "No changes."
256
+ return "Paused: " + ", ".join([f"{vid}({reason})" for vid, reason in paused])
257
+
258
+ def ui_export_csv(campaign_id: str, table: str):
259
+ path = export_csv(campaign_id, table)
260
+ return path
261
+
262
+ def ui_reset_db(confirm_text: str):
263
+ if (confirm_text or "").strip().upper() != "RESET":
264
+ raise gr.Error("タイプミス: RESET と入力してください。")
265
+ reset_all()
266
+ return "DB was reset."
267
+
268
+ def ui_adapter_send(campaign_id: str, platform: str, variant_id: str, text: str, hour: int, segment: str):
269
+ ctx = _context(hour, segment)
270
+ if platform == "x":
271
+ ad = XAdapter(campaign_id)
272
+ elif platform == "meta":
273
+ ad = MetaAdapter(campaign_id)
274
+ else:
275
+ ad = GoogleAdsAdapter(campaign_id)
276
+ res = ad.send(variant_id, text, ctx)
277
+ return json.dumps(res, ensure_ascii=False)
278
 
279
  with gr.Blocks(title="AdCopy MAB Optimizer", fill_height=True) as demo:
280
  gr.Markdown("""
281
+ # AdCopy MAB Optimizer(HF UI・拡張版
282
+ - 生成→審査→配信(Thompson / LinUCB)→レポート
283
+ - A/Aテスト・ホールドアウト、撤退基準
284
+ - CSVエスポー、DBセット、外部配信スタブ
 
285
  """)
286
 
287
  with gr.Tab("1) Generate"):
 
298
  table_gen = gr.Dataframe(headers=GENERATE_COLUMNS, interactive=False)
299
  btn_gen.click(ui_generate, [campaign_id, brand, product, target, tone, k_variants, ng_words, value_per_conv], [table_gen])
300
 
301
+ with gr.Tab("2) Settings"):
302
+ with gr.Row():
303
+ campaign_id_set = gr.Textbox(label="campaign_id", value="cmp-demo", scale=1)
304
+ policy = gr.Dropdown(choices=["thompson", "linucb"], value="thompson", label="Policy")
305
+ holdout = gr.Slider(0.0, 0.5, value=0.0, step=0.05, label="Holdout ratio")
306
+ with gr.Row():
307
+ stop_min_impr = gr.Number(value=200, label="撤退判定の最小impressions")
308
+ stop_rel_ev = gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="EVの相対しきい値(劣後停止)")
309
+ btn_set = gr.Button("設定を保存")
310
+ msg_set = gr.Markdown()
311
+ btn_set.click(ui_set_settings, [campaign_id_set, policy, holdout, stop_min_impr, stop_rel_ev], [msg_set])
312
+
313
+ with gr.Tab("3) Serve & Feedback"):
314
  with gr.Row():
315
  campaign_id2 = gr.Textbox(label="campaign_id", value="cmp-demo", scale=1)
316
  hour = gr.Slider(0, 23, value=20, step=1, label="hour")
317
  segment = gr.Textbox(label="segment (任意)")
318
+ aa_min_impr = gr.Number(value=0, label="A/Aテスト閾値(各variantのimpressionsがこの数に達するまで均等ランダム)")
319
  btn_serve = gr.Button("Serve Ad(impressionを記録)")
320
  served_vid = gr.Textbox(label="served variant_id", interactive=False)
321
  served_text = gr.Textbox(label="served text", lines=6, interactive=False)
322
+ btn_serve.click(ui_serve, [campaign_id2, hour, segment, aa_min_impr], [served_vid, served_text])
323
 
324
  with gr.Row():
325
  btn_click = gr.Button("Clickを記録")
326
  btn_conv = gr.Button("Conversionを記録")
327
  msg = gr.Markdown()
328
+ btn_click.click(lambda cid, vid, h, s: ui_feedback(cid, vid, "click", h, s), [campaign_id2, served_vid, hour, segment], [msg])
329
+ btn_conv.click(lambda cid, vid, h, s: ui_feedback(cid, vid, "conversion", h, s), [campaign_id2, served_vid, hour, segment], [msg])
330
+
331
+ gr.Markdown("### 外部配信スタブ")
332
+ with gr.Row():
333
+ platform = gr.Dropdown(choices=["x", "meta", "google"], value="x", label="プラットフォーム")
334
+ btn_send = gr.Button("この広告を外部に送る(スタブ)")
335
+ send_res = gr.Textbox(label="送信結果", lines=3)
336
+ btn_send.click(ui_adapter_send, [campaign_id2, platform, served_vid, served_text, hour, segment], [send_res])
337
 
338
+ with gr.Tab("4) Report & Ops"):
339
  campaign_id3 = gr.Textbox(label="campaign_id", value="cmp-demo")
340
+ with gr.Row():
341
+ btn_rep = gr.Button("レポート更新")
342
+ table_rep = gr.Dataframe(headers=REPORT_COLUMNS, interactive=False)
343
+ btn_rep.click(ui_report, [campaign_id3], [table_rep])
344
+ with gr.Row():
345
+ btn_stop = gr.Button("撤退基準を適用(自動pause)")
346
+ msg_stop = gr.Markdown()
347
+ btn_stop.click(ui_apply_stop, [campaign_id3], [msg_stop])
348
+
349
+ gr.Markdown("#### CSVエクスポート")
350
+ table_sel = gr.Dropdown(choices=["events","metrics","variants","compliance_logs","audit_logs"], value="metrics", label="テーブル")
351
+ btn_exp = gr.Button("エクスポート")
352
+ file_out = gr.File(label="ダウンロード", interactive=False)
353
+ btn_exp.click(ui_export_csv, [campaign_id3, table_sel], [file_out])
354
+
355
+ gr.Markdown("#### 管理用:DBリセット(全削除)")
356
+ confirm = gr.Textbox(label="タイプ: RESET", value="")
357
+ btn_reset = gr.Button("DBを初期化")
358
+ msg_reset = gr.Markdown()
359
+ btn_reset.click(ui_reset_db, [confirm], [msg_reset])
360
 
361
  if __name__ == "__main__":
362
  demo.queue().launch(server_name="0.0.0.0", server_port=7860)