SeaWolf-AI commited on
Commit
0990e16
Β·
verified Β·
1 Parent(s): 09a48cf

feat: switch to FastAPI + Docker SDK with visual UI (index.html + Chart.js)

Browse files
Files changed (7) hide show
  1. Dockerfile +33 -0
  2. README.md +2 -3
  3. app.py +154 -270
  4. requirements.txt +6 -8
  5. static/app.js +373 -0
  6. static/index.html +186 -0
  7. static/styles.css +614 -0
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
6
+ AETHER_LOG_LEVEL=INFO \
7
+ PORT=7860 \
8
+ HF_HOME=/tmp/hf_cache
9
+
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ ca-certificates \
12
+ curl \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+
17
+ COPY requirements.txt .
18
+ RUN pip install --upgrade pip && pip install -r requirements.txt
19
+
20
+ COPY . .
21
+
22
+ RUN useradd -m -u 1000 user \
23
+ && mkdir -p /tmp/hf_cache \
24
+ && chown -R user:user /app /tmp/hf_cache
25
+
26
+ USER user
27
+
28
+ EXPOSE 7860
29
+
30
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
31
+ CMD curl -fsS http://127.0.0.1:7860/health || exit 1
32
+
33
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -3,9 +3,8 @@ title: AETHER-Ad Genesis
3
  emoji: 🌌
4
  colorFrom: indigo
5
  colorTo: purple
6
- sdk: gradio
7
- sdk_version: 5.9.1
8
- app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
  short_description: Emergent Ad-Idea Engine (Blending x Greimas x Pixar)
 
3
  emoji: 🌌
4
  colorFrom: indigo
5
  colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: false
9
  license: apache-2.0
10
  short_description: Emergent Ad-Idea Engine (Blending x Greimas x Pixar)
app.py CHANGED
@@ -1,26 +1,33 @@
1
- """AETHER-Ad Genesis β€” Gradio UI for Hugging Face Space."""
2
 
3
  from __future__ import annotations
4
 
5
- import json
6
  import logging
7
  import os
8
  import sys
9
- import traceback
10
  from pathlib import Path
 
11
 
12
- import gradio as gr
 
 
 
 
 
 
13
 
14
  ROOT = Path(__file__).resolve().parent
15
  sys.path.insert(0, str(ROOT))
16
 
17
- from aether_ad.core.context import load_context
18
- from aether_ad.core.engine import GenesisEngine, ScoredSeed
19
- from aether_ad.core.genome import ProductGenome, load_genome
20
- from aether_ad.llm.fireworks_kimi import FireworksKimiBackend
21
- from aether_ad.llm.hf_inference import HFInferenceBackend
22
- from aether_ad.version import __version__
 
23
 
 
24
  logging.basicConfig(
25
  level=os.getenv("AETHER_LOG_LEVEL", "INFO"),
26
  format="%(asctime)s %(name)s %(levelname)s %(message)s",
@@ -29,284 +36,161 @@ log = logging.getLogger("aether_ad.app")
29
 
30
  PRODUCTS_DIR = ROOT / "aether_ad" / "data" / "products"
31
  TENSIONS_PATH = ROOT / "aether_ad" / "data" / "tensions" / "default_tensions.json"
32
- CONTEXT = load_context(TENSIONS_PATH)
33
- TENSIONS = CONTEXT.tension_archetypes
34
 
 
35
 
36
- def _list_products() -> list[tuple[str, str]]:
37
- items: list[tuple[str, str]] = []
38
- for p in sorted(PRODUCTS_DIR.glob("*.json")):
39
- try:
40
- g = load_genome(p)
41
- items.append((f"{g.brand} β€” {g.category}", str(p)))
42
- except Exception:
43
- continue
44
- return items
45
 
46
 
47
- def _tension_choices() -> list[tuple[str, str]]:
48
- return [(f"{t.id} β€” {t.name}", t.id) for t in TENSIONS]
 
49
 
50
 
51
- def _format_tension_detail(tension_id: str) -> str:
52
- t = CONTEXT.get_tension(tension_id)
53
- sq = t.greimas_square
54
- return (
55
- f"**{t.name}** β€” _{t.description}_ \n"
56
- f"`S1`: {sq.S1} Β· `S2`: {sq.S2} \n"
57
- f"`~S1`: {sq.not_S1} Β· `~S2`: {sq.not_S2} \n"
58
- f"**payoff**: {t.emotional_payoff}"
59
- )
60
 
61
 
62
- def _format_seeds_markdown(scored: list[ScoredSeed]) -> str:
63
- if not scored:
64
- return "_No seeds generated._"
65
- lines: list[str] = []
66
- for i, s in enumerate(scored, 1):
67
- sc = s.score
68
- lines.append(
69
- f"### #{i} Β· `{s.seed.seed_id}` β€” final **{sc.final:.3f}**\n"
70
- f"- rules: `{', '.join(s.seed.rules_applied)}`\n"
71
- f"- duration: {s.seed.duration}s Β· persona: {s.seed.persona_summary}\n\n"
72
- f"**Concept.** {s.seed.concept}\n"
73
- )
74
- lines.append("| t (s) | beat | purpose | content |")
75
- lines.append("|---|---|---|---|")
76
- for b in s.seed.beats:
77
- lines.append(
78
- f"| {b.time_range[0]}–{b.time_range[1]} | {b.beat} | {b.purpose} | "
79
- f"{(b.content or '').replace('|', '/')[:200]} |"
 
 
 
 
 
 
 
80
  )
81
- lines.append(
82
- f"\n**Scores.** novelty={sc.novelty:.2f} Β· utility={sc.utility:.2f} Β· "
83
- f"affect={sc.affect:.2f} Β· humor/pathos={sc.humor_or_pathos:.2f} Β· "
84
- f"surprise={sc.surprise:.2f} Β· risk={sc.risk:.2f} Β· "
85
- f"gating_pass={sc.gating_passed}/5\n\n---\n"
86
- )
87
- return "\n".join(lines)
88
-
89
-
90
- def _scores_table(scored: list[ScoredSeed]) -> list[list]:
91
- rows = []
92
- for i, s in enumerate(scored, 1):
93
- rows.append(
94
- [
95
- i,
96
- s.seed.seed_id,
97
- ", ".join(s.seed.rules_applied),
98
- round(s.score.novelty, 3),
99
- round(s.score.utility, 3),
100
- round(s.score.affect, 3),
101
- round(s.score.humor_or_pathos, 3),
102
- round(s.score.surprise, 3),
103
- round(s.score.risk, 3),
104
- round(s.score.final, 3),
105
- ]
106
- )
107
- return rows
108
-
109
-
110
- def run_pipeline(
111
- product_path: str,
112
- tension_id: str,
113
- persona_text: str,
114
- duration: int,
115
- n_seeds: int,
116
- top_k: int,
117
- backend_name: str,
118
- rng_seed: int,
119
- llm_model: str,
120
- progress: gr.Progress = gr.Progress(track_tqdm=False),
121
- ):
122
- try:
123
- if not product_path:
124
- return "μ œν’ˆμ„ μ„ νƒν•˜μ„Έμš”.", [], "{}", ""
125
-
126
- progress(0.05, desc="Loading genome...")
127
- genome: ProductGenome = load_genome(product_path)
128
-
129
- backend_name = (backend_name or "fireworks").lower()
130
- llm = None
131
- if backend_name != "none":
132
- progress(0.10, desc=f"Connecting {backend_name} backend...")
133
- try:
134
- if backend_name == "fireworks":
135
- if not os.getenv("FIREWORKS_API_KEY"):
136
- return (
137
- "❌ FIREWORKS_API_KEYκ°€ Space secret에 μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. "
138
- "Settings β†’ Variables and secretsμ—μ„œ FIREWORKS_API_KEYλ₯Ό μΆ”κ°€ν•˜κ±°λ‚˜, "
139
- "Backendλ₯Ό 'none'으둜 λ°”κΏ” off-LLM ν…œν”Œλ¦Ώ λͺ¨λ“œλ‘œ μ‹€ν–‰ν•˜μ„Έμš”.",
140
- [],
141
- "{}",
142
- "",
143
- )
144
- llm = FireworksKimiBackend(model=llm_model or None)
145
- elif backend_name == "hf":
146
- if not os.getenv("HF_TOKEN"):
147
- return (
148
- "❌ HF_TOKEN이 Space secret에 μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. "
149
- "Backendλ₯Ό 'none'으둜 λ°”κΏ” off-LLM ν…œν”Œλ¦Ώ λͺ¨λ“œλ‘œ μ‹€ν–‰ν•˜μ„Έμš”.",
150
- [],
151
- "{}",
152
- "",
153
- )
154
- llm = HFInferenceBackend(model=llm_model or None)
155
- else:
156
- return f"❌ Unknown backend '{backend_name}'", [], "{}", ""
157
- except Exception as e:
158
- log.exception("backend init failed")
159
- return f"LLM μ΄ˆκΈ°ν™” μ‹€νŒ¨: {e}", [], "{}", ""
160
-
161
- engine = GenesisEngine(
162
- llm=llm,
163
- context=CONTEXT,
164
- seed_count_per_run=int(n_seeds),
165
- keep_top_k=int(top_k),
166
- rng_seed=int(rng_seed) if rng_seed else None,
167
- )
168
 
169
- progress(0.25, desc="Stage 1-3: corpus β†’ encoding β†’ incubation...")
170
- progress(0.55, desc="Stage 4: emergence (Pixar spine)...")
171
- progress(0.80, desc="Stage 5: filtering (wow filter)...")
172
- scored = engine.generate(
173
- product=genome,
174
- tension_id=tension_id,
175
- persona_text=persona_text or "일반 μ†ŒλΉ„μž",
176
- duration=15 if int(duration) == 15 else 30,
177
- )
178
 
179
- md = _format_seeds_markdown(scored)
180
- rows = _scores_table(scored)
181
- raw = json.dumps([s.model_dump() for s in scored], ensure_ascii=False, indent=2)
182
- summary = (
183
- f"βœ… {len(scored)}개 μ‹œλ“œ 생성 μ™„λ£Œ (μƒμœ„ {top_k}). "
184
- f"평균 final={sum(s.score.final for s in scored) / max(1, len(scored)):.3f}, "
185
- f"졜고 final={max((s.score.final for s in scored), default=0.0):.3f}."
186
- )
187
- return summary, rows, raw, md
188
- except Exception as e:
189
- log.exception("pipeline failed")
190
- return f"❌ μ‹€ν–‰ μ‹€νŒ¨: {e}\n\n{traceback.format_exc()}", [], "{}", ""
191
 
192
 
193
- def ui() -> gr.Blocks:
194
- products = _list_products()
195
- tension_opts = _tension_choices()
196
 
197
- theme = gr.themes.Soft(primary_hue="indigo", secondary_hue="purple")
198
 
199
- with gr.Blocks(title="AETHER-Ad Genesis", theme=theme) as demo:
200
- gr.Markdown(
201
- f"# 🌌 AETHER-Ad Genesis \n"
202
- f"_창발적 κ΄‘κ³  아이���어 생성 μ—”μ§„ Β· v{__version__} Β· VIDRAFT / μ§€λ‹ˆμ  AI_ \n"
203
- "Fauconnier-Turner Γ— Greimas Γ— SCAMPER Γ— Pixar Story Spine Γ— Stuhlfaut 4D Γ— Boden Surprise"
204
- )
 
 
 
 
205
 
206
- with gr.Row():
207
- with gr.Column(scale=1):
208
- gr.Markdown("### 1️⃣ Product Genome")
209
- product_dd = gr.Dropdown(
210
- choices=products,
211
- label="Product",
212
- value=products[0][1] if products else None,
213
- )
214
 
215
- gr.Markdown("### 2️⃣ Tension Archetype (Greimas)")
216
- tension_dd = gr.Dropdown(
217
- choices=tension_opts,
218
- label="Tension",
219
- value="T01_power_asymmetry",
220
- )
221
- tension_md = gr.Markdown(_format_tension_detail("T01_power_asymmetry"))
 
 
 
 
 
 
222
 
223
- gr.Markdown("### 3️⃣ Persona")
224
- persona = gr.Textbox(
225
- label="Persona (free-form Korean)",
226
- value="10λŒ€ 남학생, 쒌절",
227
- lines=2,
 
 
228
  )
229
-
230
- gr.Markdown("### 4️⃣ Pipeline")
231
- with gr.Row():
232
- duration = gr.Radio([15, 30], value=15, label="Duration (s)")
233
- n_seeds = gr.Slider(5, 50, value=15, step=1, label="n_seeds")
234
- top_k = gr.Slider(1, 10, value=5, step=1, label="top_k")
235
- with gr.Row():
236
- backend_dd = gr.Dropdown(
237
- choices=[
238
- ("Fireworks Β· Kimi-K2P6 (default)", "fireworks"),
239
- ("HF Inference Β· Qwen2.5-72B", "hf"),
240
- ("none (template fallback)", "none"),
241
- ],
242
- value="fireworks",
243
- label="LLM backend",
244
- )
245
- rng_seed_in = gr.Number(value=42, precision=0, label="rng_seed")
246
- llm_model = gr.Textbox(
247
- label="Model id (leave blank to use backend default)",
248
- value=os.getenv(
249
- "FIREWORKS_MODEL", "accounts/fireworks/models/kimi-k2p6"
250
- ),
251
  )
252
- run_btn = gr.Button("⚑ Generate", variant="primary")
253
-
254
- with gr.Column(scale=2):
255
- gr.Markdown("### 🎬 Output")
256
- summary = gr.Markdown("")
257
- with gr.Tabs():
258
- with gr.TabItem("Scored table"):
259
- scores_table = gr.Dataframe(
260
- headers=[
261
- "rank", "seed_id", "rules",
262
- "novelty", "utility", "affect",
263
- "humor/pathos", "surprise", "risk", "final",
264
- ],
265
- interactive=False,
266
- wrap=True,
267
- )
268
- with gr.TabItem("Storyboards"):
269
- storyboards_md = gr.Markdown()
270
- with gr.TabItem("Raw JSON"):
271
- raw_json = gr.Code(language="json")
272
-
273
- tension_dd.change(fn=_format_tension_detail, inputs=tension_dd, outputs=tension_md)
274
-
275
- run_btn.click(
276
- fn=run_pipeline,
277
- inputs=[
278
- product_dd, tension_dd, persona, duration, n_seeds, top_k,
279
- backend_dd, rng_seed_in, llm_model,
280
- ],
281
- outputs=[summary, scores_table, raw_json, storyboards_md],
282
- )
283
-
284
- with gr.Accordion("πŸ“š Theoretical Pillars", open=False):
285
- gr.Markdown(
286
- """
287
- - **Fauconnier & Turner (2002)** β€” *The Way We Think*, Conceptual Blending.
288
- - **Greimas (1966)** β€” *SΓ©mantique structurale*, Semiotic Square (S1 / S2 / ~S1 / ~S2).
289
- - **Eberle (1971)** β€” SCAMPER (Substitute Β· Combine Β· Adapt Β· Modify Β· Put-to Β· Eliminate Β· Reverse).
290
- - **Stuhlfaut & Yoo (2011)** β€” Advertising Creativity 4-D Model.
291
- - **Boden (2007)** β€” Novelty Β· Utility Β· Surprise.
292
- - **Coats / McKee (2011)** β€” Pixar Story Spine (6 beats).
293
- - **Floch (1990)** β€” Four consumption-value axes (practical Β· utopian Β· critical Β· playful).
294
- """
295
- )
296
-
297
- with gr.Accordion("πŸ§ͺ 15 Collision Rules", open=False):
298
- from aether_ad.core.collision import ALL_RULES
299
- rows = [[r.rule_id, r.name, r.pattern, r.theoretical_anchor] for r in ALL_RULES]
300
- gr.Dataframe(
301
- value=rows,
302
- headers=["rule_id", "name", "pattern", "theoretical_anchor"],
303
- interactive=False,
304
- wrap=True,
305
- )
306
-
307
- return demo
308
-
309
 
310
- if __name__ == "__main__":
311
- demo = ui()
312
- demo.queue(max_size=8).launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AETHER-Ad Genesis β€” FastAPI backend for the visual HF Space (Docker SDK)."""
2
 
3
  from __future__ import annotations
4
 
 
5
  import logging
6
  import os
7
  import sys
 
8
  from pathlib import Path
9
+ from typing import Any, Literal
10
 
11
+ from dotenv import load_dotenv
12
+ from fastapi import FastAPI, HTTPException
13
+ from fastapi.concurrency import run_in_threadpool
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import FileResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel, Field
18
 
19
  ROOT = Path(__file__).resolve().parent
20
  sys.path.insert(0, str(ROOT))
21
 
22
+ from aether_ad.core.collision import ALL_RULES # noqa: E402
23
+ from aether_ad.core.context import load_context # noqa: E402
24
+ from aether_ad.core.engine import GenesisEngine # noqa: E402
25
+ from aether_ad.core.genome import load_genome # noqa: E402
26
+ from aether_ad.llm.fireworks_kimi import FireworksKimiBackend # noqa: E402
27
+ from aether_ad.llm.hf_inference import HFInferenceBackend # noqa: E402
28
+ from aether_ad.version import __version__ # noqa: E402
29
 
30
+ load_dotenv()
31
  logging.basicConfig(
32
  level=os.getenv("AETHER_LOG_LEVEL", "INFO"),
33
  format="%(asctime)s %(name)s %(levelname)s %(message)s",
 
36
 
37
  PRODUCTS_DIR = ROOT / "aether_ad" / "data" / "products"
38
  TENSIONS_PATH = ROOT / "aether_ad" / "data" / "tensions" / "default_tensions.json"
39
+ STATIC_DIR = ROOT / "static"
 
40
 
41
+ CONTEXT = load_context(TENSIONS_PATH)
42
 
43
+ app = FastAPI(title="AETHER-Ad Genesis", version=__version__)
44
+ app.add_middleware(
45
+ CORSMiddleware,
46
+ allow_origins=["*"],
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
 
 
50
 
51
 
52
+ @app.get("/", include_in_schema=False)
53
+ async def index() -> FileResponse:
54
+ return FileResponse(STATIC_DIR / "index.html")
55
 
56
 
57
+ @app.get("/health")
58
+ async def health() -> dict[str, Any]:
59
+ return {"status": "ok", "version": __version__}
 
 
 
 
 
 
60
 
61
 
62
+ @app.get("/api/meta")
63
+ async def meta() -> dict[str, Any]:
64
+ return {
65
+ "version": __version__,
66
+ "default_backend": os.getenv("AETHER_DEFAULT_BACKEND", "fireworks"),
67
+ "has_fireworks_key": bool(os.getenv("FIREWORKS_API_KEY")),
68
+ "has_hf_token": bool(os.getenv("HF_TOKEN")),
69
+ }
70
+
71
+
72
+ @app.get("/api/products")
73
+ async def products() -> list[dict[str, Any]]:
74
+ items: list[dict[str, Any]] = []
75
+ for p in sorted(PRODUCTS_DIR.glob("*.json")):
76
+ try:
77
+ g = load_genome(p)
78
+ items.append(
79
+ {
80
+ "product_id": g.product_id,
81
+ "brand": g.brand,
82
+ "category": g.category,
83
+ "price_tier": g.price_tier,
84
+ "atom_count": len(g.functional_atoms),
85
+ "atoms": [{"id": a.id, "name": a.name} for a in g.functional_atoms],
86
+ }
87
  )
88
+ except Exception as e:
89
+ log.warning("skip product %s: %s", p, e)
90
+ return items
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
 
 
 
 
 
 
 
 
 
92
 
93
+ @app.get("/api/tensions")
94
+ async def tensions() -> list[dict[str, Any]]:
95
+ return [t.model_dump() for t in CONTEXT.tension_archetypes]
 
 
 
 
 
 
 
 
 
96
 
97
 
98
+ @app.get("/api/rules")
99
+ async def rules() -> list[dict[str, Any]]:
100
+ return [r.model_dump() for r in ALL_RULES]
101
 
 
102
 
103
+ class GenerateRequest(BaseModel):
104
+ product_id: str
105
+ tension_id: str
106
+ persona: str = "일반 μ†ŒλΉ„μž"
107
+ duration: Literal[15, 30] = 15
108
+ n_seeds: int = Field(15, ge=1, le=50)
109
+ top_k: int = Field(5, ge=1, le=20)
110
+ backend: Literal["fireworks", "hf", "none"] = "fireworks"
111
+ model: str | None = None
112
+ rng_seed: int | None = 42
113
 
 
 
 
 
 
 
 
 
114
 
115
+ @app.post("/api/generate")
116
+ async def generate(req: GenerateRequest) -> dict[str, Any]:
117
+ product_path: Path | None = None
118
+ for p in PRODUCTS_DIR.glob("*.json"):
119
+ try:
120
+ if load_genome(p).product_id == req.product_id:
121
+ product_path = p
122
+ break
123
+ except Exception:
124
+ continue
125
+ if product_path is None:
126
+ raise HTTPException(status_code=404, detail=f"Product '{req.product_id}' not found")
127
+ genome = load_genome(product_path)
128
 
129
+ llm = None
130
+ try:
131
+ if req.backend == "fireworks":
132
+ if not os.getenv("FIREWORKS_API_KEY"):
133
+ raise HTTPException(
134
+ status_code=400,
135
+ detail="FIREWORKS_API_KEY is not set in Space secrets.",
136
  )
137
+ llm = FireworksKimiBackend(model=req.model or None)
138
+ elif req.backend == "hf":
139
+ if not os.getenv("HF_TOKEN"):
140
+ raise HTTPException(
141
+ status_code=400,
142
+ detail="HF_TOKEN is not set in Space secrets.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  )
144
+ llm = HFInferenceBackend(model=req.model or None)
145
+ except HTTPException:
146
+ raise
147
+ except Exception as e:
148
+ log.exception("backend init failed")
149
+ raise HTTPException(status_code=500, detail=f"Backend init failed: {e}") from e
150
+
151
+ engine = GenesisEngine(
152
+ llm=llm,
153
+ context=CONTEXT,
154
+ seed_count_per_run=req.n_seeds,
155
+ keep_top_k=req.top_k,
156
+ rng_seed=req.rng_seed,
157
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
+ try:
160
+ scored = await run_in_threadpool(
161
+ engine.generate,
162
+ product=genome,
163
+ tension_id=req.tension_id,
164
+ persona_text=req.persona or "일반 μ†ŒλΉ„μž",
165
+ duration=req.duration,
166
+ )
167
+ except Exception as e:
168
+ log.exception("pipeline failed")
169
+ raise HTTPException(status_code=500, detail=f"Pipeline failed: {e}") from e
170
+
171
+ seeds = [s.model_dump() for s in scored]
172
+ avg_final = sum(s["score"]["final"] for s in seeds) / max(1, len(seeds))
173
+ top_final = max((s["score"]["final"] for s in seeds), default=0.0)
174
+
175
+ return {
176
+ "summary": {
177
+ "count": len(seeds),
178
+ "avg_final": avg_final,
179
+ "top_final": top_final,
180
+ "product_id": req.product_id,
181
+ "tension_id": req.tension_id,
182
+ "backend": req.backend,
183
+ "model": req.model,
184
+ "duration": req.duration,
185
+ },
186
+ "seeds": seeds,
187
+ }
188
+
189
+
190
+ if STATIC_DIR.exists():
191
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
192
+
193
+
194
+ if __name__ == "__main__": # pragma: no cover
195
+ import uvicorn
196
+ uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
requirements.txt CHANGED
@@ -1,15 +1,13 @@
1
  pydantic>=2.5.0
2
  jsonschema>=4.20.0
3
- openai>=1.30.0
4
  httpx>=0.27.0
5
  python-dotenv>=1.0.0
 
 
 
 
6
  typer>=0.12.0
7
  rich>=13.7.0
8
  pyyaml>=6.0.1
9
- numpy>=1.26.0
10
- scikit-learn>=1.4.0
11
- sentence-transformers>=2.7.0
12
- structlog>=24.1.0
13
- huggingface_hub>=0.24.0
14
- gradio>=5.0.0
15
- audioop-lts>=0.2.1; python_version>="3.13"
 
1
  pydantic>=2.5.0
2
  jsonschema>=4.20.0
 
3
  httpx>=0.27.0
4
  python-dotenv>=1.0.0
5
+ numpy>=1.26.0
6
+ huggingface_hub>=0.24.0
7
+ structlog>=24.1.0
8
+ openai>=1.30.0
9
  typer>=0.12.0
10
  rich>=13.7.0
11
  pyyaml>=6.0.1
12
+ fastapi>=0.110.0
13
+ uvicorn[standard]>=0.27.0
 
 
 
 
 
static/app.js ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ const $ = (sel, el = document) => el.querySelector(sel);
4
+ const $$ = (sel, el = document) => Array.from(el.querySelectorAll(sel));
5
+
6
+ const API = {
7
+ meta: () => fetch('/api/meta').then(r => r.json()),
8
+ products: () => fetch('/api/products').then(r => r.json()),
9
+ tensions: () => fetch('/api/tensions').then(r => r.json()),
10
+ rules: () => fetch('/api/rules').then(r => r.json()),
11
+ generate: (body) => fetch('/api/generate', {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify(body),
15
+ }).then(async r => {
16
+ if (!r.ok) {
17
+ const j = await r.json().catch(() => ({ detail: r.statusText }));
18
+ throw new Error(j.detail || `HTTP ${r.status}`);
19
+ }
20
+ return r.json();
21
+ }),
22
+ };
23
+
24
+ let TENSIONS = [];
25
+ let PRODUCTS = [];
26
+ let CHARTS = [];
27
+ let PIPELINE_TIMER = null;
28
+
29
+ document.addEventListener('DOMContentLoaded', init);
30
+
31
+ async function init() {
32
+ wireInputs();
33
+ await Promise.all([loadMeta(), loadProducts(), loadTensions(), loadRules()]);
34
+ }
35
+
36
+ async function loadMeta() {
37
+ try {
38
+ const m = await API.meta();
39
+ $('#version').textContent = m.version;
40
+ const sel = $('#backendSel');
41
+ sel.querySelectorAll('option').forEach(o => {
42
+ if (o.value === 'fireworks' && !m.has_fireworks_key) {
43
+ o.disabled = true; o.textContent += ' (no key)';
44
+ }
45
+ if (o.value === 'hf' && !m.has_hf_token) {
46
+ o.disabled = true; o.textContent += ' (no token)';
47
+ }
48
+ });
49
+ sel.value = m.default_backend || 'fireworks';
50
+ syncBackendPlaceholder();
51
+ } catch (e) { console.error('meta failed', e); }
52
+ }
53
+
54
+ async function loadProducts() {
55
+ PRODUCTS = await API.products();
56
+ const sel = $('#productSel'); sel.innerHTML = '';
57
+ PRODUCTS.forEach(p => {
58
+ const o = document.createElement('option');
59
+ o.value = p.product_id;
60
+ o.textContent = `${p.brand} Β· ${p.category}`;
61
+ sel.appendChild(o);
62
+ });
63
+ updateProductMeta();
64
+ }
65
+
66
+ function updateProductMeta() {
67
+ const pid = $('#productSel').value;
68
+ const p = PRODUCTS.find(x => x.product_id === pid);
69
+ if (!p) return;
70
+ const atomList = (p.atoms || []).map(a => a.name).join(' Β· ');
71
+ $('#productMeta').innerHTML = `
72
+ <strong>${p.product_id}</strong> Β· tier: <strong>${p.price_tier || 'β€”'}</strong> Β· ${p.atom_count} functional atoms
73
+ <br/><span style="color: var(--text-subtle);">${atomList}</span>
74
+ `;
75
+ }
76
+
77
+ async function loadTensions() {
78
+ TENSIONS = await API.tensions();
79
+ const sel = $('#tensionSel'); sel.innerHTML = '';
80
+ TENSIONS.forEach(t => {
81
+ const o = document.createElement('option');
82
+ o.value = t.id; o.textContent = `${t.id} Β· ${t.name}`;
83
+ sel.appendChild(o);
84
+ });
85
+ updateGreimas();
86
+ }
87
+
88
+ function updateGreimas() {
89
+ const id = $('#tensionSel').value;
90
+ const t = TENSIONS.find(x => x.id === id);
91
+ if (!t) return;
92
+ const sq = t.greimas_square;
93
+ $('[data-pos="s1"]').textContent = sq.S1;
94
+ $('[data-pos="s2"]').textContent = sq.S2;
95
+ $('[data-pos="ns1"]').textContent = sq.not_S1;
96
+ $('[data-pos="ns2"]').textContent = sq.not_S2;
97
+ $('#tensionPayoff').innerHTML = `${escapeHtml(t.description || '')}<br/><strong>payoff:</strong> ${escapeHtml(t.emotional_payoff)}`;
98
+ }
99
+
100
+ async function loadRules() {
101
+ const rules = await API.rules();
102
+ const rows = [
103
+ `<div class="rule-row header">
104
+ <div>rule_id</div><div>name</div><div>pattern</div><div>theoretical_anchor</div>
105
+ </div>`,
106
+ ];
107
+ rules.forEach(r => {
108
+ rows.push(`
109
+ <div class="rule-row">
110
+ <div class="rule-id">${r.rule_id}</div>
111
+ <div><strong>${escapeHtml(r.name)}</strong></div>
112
+ <div>${escapeHtml(r.pattern)}</div>
113
+ <div class="rule-anchor">${escapeHtml(r.theoretical_anchor)}</div>
114
+ </div>`);
115
+ });
116
+ $('#rulesTable').innerHTML = rows.join('');
117
+ }
118
+
119
+ function wireInputs() {
120
+ $('#productSel').addEventListener('change', updateProductMeta);
121
+ $('#tensionSel').addEventListener('change', updateGreimas);
122
+ $('#nSeeds').addEventListener('input', e => $('#nSeedsVal').textContent = e.target.value);
123
+ $('#topK').addEventListener('input', e => $('#topKVal').textContent = e.target.value);
124
+ $('#backendSel').addEventListener('change', syncBackendPlaceholder);
125
+ $('#genBtn').addEventListener('click', runGenerate);
126
+ }
127
+
128
+ function syncBackendPlaceholder() {
129
+ const v = $('#backendSel').value;
130
+ const placeholders = {
131
+ fireworks: 'accounts/fireworks/models/kimi-k2p6',
132
+ hf: 'Qwen/Qwen2.5-72B-Instruct',
133
+ none: '(not used)',
134
+ };
135
+ $('#modelIn').placeholder = placeholders[v] || '';
136
+ }
137
+
138
+ async function runGenerate() {
139
+ const body = {
140
+ product_id: $('#productSel').value,
141
+ tension_id: $('#tensionSel').value,
142
+ persona: $('#personaIn').value.trim() || '일반 μ†ŒλΉ„μž',
143
+ duration: parseInt($$('input[name="duration"]').find(r => r.checked).value, 10),
144
+ n_seeds: parseInt($('#nSeeds').value, 10),
145
+ top_k: parseInt($('#topK').value, 10),
146
+ backend: $('#backendSel').value,
147
+ model: $('#modelIn').value.trim() || null,
148
+ rng_seed: parseInt($('#rngSeed').value || '42', 10),
149
+ };
150
+
151
+ setRunning(true);
152
+ $('#errBox').hidden = true;
153
+ $('#emptyState').style.display = 'none';
154
+ animatePipeline();
155
+
156
+ try {
157
+ const r = await API.generate(body);
158
+ completePipeline();
159
+ renderResults(r);
160
+ } catch (err) {
161
+ resetPipeline();
162
+ const msg = err && err.message ? err.message : String(err);
163
+ $('#errBox').hidden = false;
164
+ $('#errBox').textContent = `❌ ${msg}`;
165
+ if (!$('#summaryBar').hidden) { /* keep previous results visible */ }
166
+ else $('#emptyState').style.display = '';
167
+ console.error(err);
168
+ } finally {
169
+ setRunning(false);
170
+ }
171
+ }
172
+
173
+ function setRunning(running) {
174
+ $('#genBtn').disabled = running;
175
+ $('#genBtn .btn-spinner').hidden = !running;
176
+ $('#genBtn .btn-label').textContent = running ? '생성 쀑…' : '⚑ Generate';
177
+ }
178
+
179
+ function animatePipeline() {
180
+ resetPipeline();
181
+ const stages = ['corpus', 'encoding', 'incubation', 'emergence', 'filtering'];
182
+ let i = 0;
183
+ const tick = () => {
184
+ if (i > 0) {
185
+ const prev = document.querySelector(`.stage[data-stage="${stages[i-1]}"]`);
186
+ prev.classList.remove('active'); prev.classList.add('done');
187
+ }
188
+ if (i < stages.length) {
189
+ document.querySelector(`.stage[data-stage="${stages[i]}"]`).classList.add('active');
190
+ i++;
191
+ PIPELINE_TIMER = setTimeout(tick, 1200);
192
+ }
193
+ };
194
+ tick();
195
+ }
196
+
197
+ function completePipeline() {
198
+ if (PIPELINE_TIMER) { clearTimeout(PIPELINE_TIMER); PIPELINE_TIMER = null; }
199
+ $$('.stage').forEach(s => { s.classList.remove('active'); s.classList.add('done'); });
200
+ }
201
+
202
+ function resetPipeline() {
203
+ if (PIPELINE_TIMER) { clearTimeout(PIPELINE_TIMER); PIPELINE_TIMER = null; }
204
+ $$('.stage').forEach(s => s.classList.remove('active', 'done'));
205
+ }
206
+
207
+ function renderResults(data) {
208
+ $('#summaryBar').hidden = false;
209
+ $('#sumCount').textContent = data.summary.count;
210
+ $('#sumAvg').textContent = (data.summary.avg_final || 0).toFixed(3);
211
+ $('#sumTop').textContent = (data.summary.top_final || 0).toFixed(3);
212
+ $('#sumBackend').textContent = data.summary.backend + (data.summary.model ? ' Β· ' + data.summary.model.split('/').pop() : '');
213
+
214
+ CHARTS.forEach(c => { try { c.destroy(); } catch(_){} });
215
+ CHARTS = [];
216
+ const box = $('#seedsBox'); box.innerHTML = '';
217
+
218
+ data.seeds.forEach((s, idx) => {
219
+ const card = renderSeedCard(s, idx + 1);
220
+ box.appendChild(card);
221
+ });
222
+ }
223
+
224
+ function renderSeedCard(s, rank) {
225
+ const seed = s.seed;
226
+ const score = s.score;
227
+ const final = score.final;
228
+ const color = final >= 0.5 ? '#10b981' : final >= 0.3 ? '#f59e0b' : '#ef4444';
229
+ const R = 34, CIRC = 2 * Math.PI * R;
230
+ const offset = CIRC * (1 - Math.max(0, Math.min(1, final)));
231
+
232
+ const card = document.createElement('div');
233
+ card.className = 'seed-card';
234
+ card.innerHTML = `
235
+ <div class="seed-head">
236
+ <div class="seed-rank-block">
237
+ <span class="seed-rank-label">Rank</span>
238
+ <div class="seed-rank">#${rank}</div>
239
+ </div>
240
+ <div class="seed-info">
241
+ <div class="seed-id">${escapeHtml(seed.seed_id)}</div>
242
+ <div class="seed-rules">
243
+ ${seed.rules_applied.map(r => `<span class="rule-pill">${escapeHtml(r)}</span>`).join('')}
244
+ </div>
245
+ </div>
246
+ <div class="final-gauge" title="final score">
247
+ <svg viewBox="0 0 80 80">
248
+ <circle class="final-gauge-track" cx="40" cy="40" r="${R}"/>
249
+ <circle class="final-gauge-fill" cx="40" cy="40" r="${R}"
250
+ stroke="${color}"
251
+ stroke-dasharray="${CIRC}"
252
+ stroke-dashoffset="${offset}"/>
253
+ </svg>
254
+ <div class="final-gauge-text">
255
+ <div>
256
+ <div class="final-gauge-value">${final.toFixed(2)}</div>
257
+ <div class="final-gauge-label">final</div>
258
+ </div>
259
+ </div>
260
+ </div>
261
+ </div>
262
+
263
+ <div class="seed-concept">${escapeHtml(seed.concept)}</div>
264
+
265
+ <div class="seed-section-title">Pixar Story Spine Β· ${seed.duration}s Β· ${seed.beats.length} beats</div>
266
+ <div class="timeline">
267
+ <div class="timeline-bar">${renderBeatBar(seed.beats, seed.duration)}</div>
268
+ <div class="timeline-scale">${renderTicks(seed.duration)}</div>
269
+ </div>
270
+
271
+ <div class="seed-section-title">Beats</div>
272
+ <div class="beats-detail">
273
+ ${seed.beats.map(b => `
274
+ <div class="beat-row">
275
+ <div class="beat-row-time">${b.time_range[0]}–${b.time_range[1]}s</div>
276
+ <div class="beat-row-name">${escapeHtml(b.beat)}</div>
277
+ <div class="beat-row-content">${escapeHtml(b.content || 'β€”')}</div>
278
+ </div>
279
+ `).join('')}
280
+ </div>
281
+
282
+ <div class="scores-section">
283
+ <div class="radar-wrap"><canvas></canvas></div>
284
+ <div class="scores-table">
285
+ ${scoreItem('Novelty', score.novelty)}
286
+ ${scoreItem('Utility', score.utility)}
287
+ ${scoreItem('Affect', score.affect)}
288
+ ${scoreItem('Humor/Pathos', score.humor_or_pathos)}
289
+ ${scoreItem('Surprise', score.surprise)}
290
+ ${scoreItem('Risk', score.risk, { risk: true })}
291
+ ${scoreItem('Gating', (score.gating_passed || 0) + '/5', { raw: true })}
292
+ ${scoreItem('Final', score.final, { bold: true })}
293
+ </div>
294
+ </div>
295
+ `;
296
+
297
+ const canvas = $('canvas', card);
298
+ const chart = new Chart(canvas.getContext('2d'), {
299
+ type: 'radar',
300
+ data: {
301
+ labels: ['Novelty', 'Utility', 'Affect', 'Humor/Pathos', 'Surprise'],
302
+ datasets: [{
303
+ data: [
304
+ score.novelty, score.utility, score.affect,
305
+ score.humor_or_pathos, score.surprise,
306
+ ],
307
+ backgroundColor: 'rgba(79, 70, 229, 0.18)',
308
+ borderColor: '#4f46e5',
309
+ borderWidth: 2,
310
+ pointBackgroundColor: '#4f46e5',
311
+ pointBorderColor: '#ffffff',
312
+ pointRadius: 3,
313
+ }],
314
+ },
315
+ options: {
316
+ responsive: true, maintainAspectRatio: false,
317
+ plugins: { legend: { display: false } },
318
+ scales: {
319
+ r: {
320
+ suggestedMin: 0, suggestedMax: 1,
321
+ ticks: { display: false, stepSize: 0.25 },
322
+ grid: { color: '#e6e9f0' },
323
+ angleLines: { color: '#e6e9f0' },
324
+ pointLabels:{ font: { size: 10, weight: '600' }, color: '#475569' },
325
+ },
326
+ },
327
+ },
328
+ });
329
+ CHARTS.push(chart);
330
+ return card;
331
+ }
332
+
333
+ function renderBeatBar(beats, duration) {
334
+ return beats.map((b, i) => {
335
+ const pct = ((b.time_range[1] - b.time_range[0]) / duration) * 100;
336
+ const short = (b.beat || '').split('_')[0] || b.beat;
337
+ return `
338
+ <div class="beat-seg beat-color-${Math.min(i, 5)}" style="width: ${pct}%;"
339
+ title="${escapeHtml(b.beat)} Β· ${b.time_range[0]}-${b.time_range[1]}s">
340
+ <div class="beat-seg-name">${escapeHtml(short)}</div>
341
+ <div class="beat-seg-time">${b.time_range[0]}-${b.time_range[1]}s</div>
342
+ </div>
343
+ `;
344
+ }).join('');
345
+ }
346
+
347
+ function renderTicks(duration) {
348
+ const ticks = duration === 15 ? [0, 3, 6, 9, 12, 15] : [0, 5, 10, 15, 20, 25, 30];
349
+ return ticks.map(t => {
350
+ const left = (t / duration) * 100;
351
+ return `<span class="timeline-tick" style="left: ${left}%;">${t}s</span>`;
352
+ }).join('');
353
+ }
354
+
355
+ function scoreItem(label, val, opts = {}) {
356
+ const v = opts.raw ? val : (typeof val === 'number' ? val.toFixed(3) : val);
357
+ const style = [];
358
+ if (opts.risk && Number(val) > 0) style.push('color: var(--danger);');
359
+ if (opts.bold) style.push('color: var(--primary); font-size: 15px;');
360
+ return `
361
+ <div class="score-item">
362
+ <span class="score-item-label">${label}</span>
363
+ <span class="score-item-value" style="${style.join(' ')}">${v}</span>
364
+ </div>
365
+ `;
366
+ }
367
+
368
+ function escapeHtml(s) {
369
+ if (s == null) return '';
370
+ return String(s).replace(/[&<>"']/g, ch => ({
371
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;',
372
+ }[ch]));
373
+ }
static/index.html ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>AETHER-Ad Genesis Β· Emergent Ad-Idea Engine</title>
7
+ <meta name="description" content="Fauconnier-Turner Γ— Greimas Γ— SCAMPER Γ— Pixar Story Spine Γ— Stuhlfaut 4D Γ— Boden Surprise β€” emergent ad-idea generation engine." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
11
+ <link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" rel="stylesheet" />
12
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script>
13
+ <link rel="stylesheet" href="/static/styles.css" />
14
+ </head>
15
+ <body>
16
+ <header class="hero">
17
+ <div class="container hero-inner">
18
+ <div class="hero-brand">
19
+ <div class="hero-logo">🌌</div>
20
+ <div>
21
+ <h1 class="hero-title">AETHER-Ad <span class="grad">Genesis</span></h1>
22
+ <p class="hero-subtitle">창발적 κ΄‘κ³  아이디어 생성 μ—”μ§„ Β· v<span id="version">0.2.0</span> Β· VIDRAFT Β· μ§€λ‹ˆμ  AI</p>
23
+ </div>
24
+ </div>
25
+ <div class="hero-pillars">
26
+ <span class="pill">Fauconnier-Turner Β· Blending</span>
27
+ <span class="pill">Greimas Β· Semiotic Square</span>
28
+ <span class="pill">Eberle Β· SCAMPER</span>
29
+ <span class="pill">Coats Β· Pixar Spine</span>
30
+ <span class="pill">Stuhlfaut Β· 4-D Creativity</span>
31
+ <span class="pill">Boden Β· Surprise</span>
32
+ </div>
33
+ </div>
34
+ </header>
35
+
36
+ <main class="container main">
37
+ <aside class="sidebar">
38
+ <div class="card">
39
+ <div class="card-header"><h2><span class="step">1</span>Product Genome</h2></div>
40
+ <div class="field">
41
+ <label for="productSel">Product</label>
42
+ <select id="productSel"></select>
43
+ <div id="productMeta" class="meta"></div>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="card">
48
+ <div class="card-header"><h2><span class="step">2</span>Tension <small>Greimas Square</small></h2></div>
49
+ <div class="field">
50
+ <label for="tensionSel">Tension archetype</label>
51
+ <select id="tensionSel"></select>
52
+ </div>
53
+ <div class="greimas">
54
+ <div class="g-cell g-s1"><span class="g-label">S1 positive</span><span class="g-value" data-pos="s1">β€”</span></div>
55
+ <div class="g-cell g-s2"><span class="g-label">S2 contrary</span><span class="g-value" data-pos="s2">β€”</span></div>
56
+ <div class="g-cell g-ns1"><span class="g-label">~S1 contradiction</span><span class="g-value" data-pos="ns1">β€”</span></div>
57
+ <div class="g-cell g-ns2"><span class="g-label">~S2 contradiction</span><span class="g-value" data-pos="ns2">β€”</span></div>
58
+ </div>
59
+ <div id="tensionPayoff" class="meta"></div>
60
+ </div>
61
+
62
+ <div class="card">
63
+ <div class="card-header"><h2><span class="step">3</span>Persona</h2></div>
64
+ <div class="field">
65
+ <label for="personaIn">Target persona (free-text)</label>
66
+ <textarea id="personaIn" rows="2" placeholder="예: 10λŒ€ 남학생, 쒌절">10λŒ€ 남학생, 쒌절</textarea>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="card">
71
+ <div class="card-header"><h2><span class="step">4</span>Pipeline</h2></div>
72
+ <div class="field">
73
+ <label>Spot duration</label>
74
+ <div class="radio-group">
75
+ <label class="radio-chip"><input type="radio" name="duration" value="15" checked /><span>15s Β· 5 beats</span></label>
76
+ <label class="radio-chip"><input type="radio" name="duration" value="30" /><span>30s Β· 6 beats</span></label>
77
+ </div>
78
+ </div>
79
+ <div class="field">
80
+ <label>n_seeds Β· <output id="nSeedsVal">15</output></label>
81
+ <input type="range" id="nSeeds" min="5" max="40" value="15" />
82
+ </div>
83
+ <div class="field">
84
+ <label>top_k Β· <output id="topKVal">5</output></label>
85
+ <input type="range" id="topK" min="1" max="10" value="5" />
86
+ </div>
87
+ <div class="field">
88
+ <label for="backendSel">LLM backend</label>
89
+ <select id="backendSel">
90
+ <option value="fireworks" selected>Fireworks Β· Kimi-K2P6</option>
91
+ <option value="hf">HF Inference Β· Qwen2.5-72B</option>
92
+ <option value="none">Template (off-LLM)</option>
93
+ </select>
94
+ </div>
95
+ <div class="field">
96
+ <label for="modelIn">Model id <small>(optional override)</small></label>
97
+ <input type="text" id="modelIn" placeholder="accounts/fireworks/models/kimi-k2p6" />
98
+ </div>
99
+ <div class="field">
100
+ <label for="rngSeed">rng_seed</label>
101
+ <input type="number" id="rngSeed" value="42" />
102
+ </div>
103
+ <button id="genBtn" class="btn-primary">
104
+ <span class="btn-spinner" hidden></span>
105
+ <span class="btn-label">⚑ Generate</span>
106
+ </button>
107
+ <div id="errBox" class="err" hidden></div>
108
+ </div>
109
+ </aside>
110
+
111
+ <section class="results">
112
+ <div class="pipeline card-flat">
113
+ <h3 class="pipeline-title">5-Stage Pipeline Β· Young(1940) Adapted</h3>
114
+ <div class="pipeline-stages">
115
+ <div class="stage" data-stage="corpus">
116
+ <div class="stage-dot"></div>
117
+ <div class="stage-label">Corpus</div>
118
+ </div>
119
+ <div class="stage" data-stage="encoding">
120
+ <div class="stage-dot"></div>
121
+ <div class="stage-label">Encoding</div>
122
+ </div>
123
+ <div class="stage" data-stage="incubation">
124
+ <div class="stage-dot"></div>
125
+ <div class="stage-label">Incubation</div>
126
+ </div>
127
+ <div class="stage" data-stage="emergence">
128
+ <div class="stage-dot"></div>
129
+ <div class="stage-label">Emergence</div>
130
+ </div>
131
+ <div class="stage" data-stage="filtering">
132
+ <div class="stage-dot"></div>
133
+ <div class="stage-label">Filtering</div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <div id="emptyState" class="empty">
139
+ <div class="empty-icon">✨</div>
140
+ <h3>창발적 κ΄‘κ³  아이디어λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€</h3>
141
+ <p>μ’ŒμΈ‘μ—μ„œ μ œν’ˆ Β· κΈ΄μž₯ Β· 페λ₯΄μ†Œλ‚˜λ₯Ό μ„€μ •ν•˜κ³  <strong>Generate</strong>λ₯Ό λˆ„λ₯΄μ„Έμš”.<br/>
142
+ Fireworks Kimi-K2P6이 6개 이둠 ν”„λ ˆμž„μ„ κ²½μœ ν•΄ Top-K μ‹œλ“œλ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€.</p>
143
+ </div>
144
+
145
+ <div id="summaryBar" class="summary" hidden>
146
+ <div class="summary-metric"><div class="summary-label">Seeds</div><div class="summary-value" id="sumCount">β€”</div></div>
147
+ <div class="summary-metric"><div class="summary-label">Avg final</div><div class="summary-value" id="sumAvg">β€”</div></div>
148
+ <div class="summary-metric"><div class="summary-label">Top final</div><div class="summary-value" id="sumTop">β€”</div></div>
149
+ <div class="summary-metric"><div class="summary-label">Backend</div><div class="summary-value summary-value-sm" id="sumBackend">β€”</div></div>
150
+ </div>
151
+
152
+ <div id="seedsBox" class="seeds"></div>
153
+ </section>
154
+ </main>
155
+
156
+ <footer class="footer container">
157
+ <details class="foot-details">
158
+ <summary>πŸ“š Theoretical Foundation</summary>
159
+ <div class="foot-body">
160
+ <ul>
161
+ <li><strong>Fauconnier &amp; Turner (2002)</strong> β€” <em>The Way We Think</em>, Conceptual Blending.</li>
162
+ <li><strong>Greimas (1966)</strong> β€” <em>SΓ©mantique structurale</em>, Semiotic Square (S1 / S2 / ~S1 / ~S2).</li>
163
+ <li><strong>Eberle (1971)</strong> β€” SCAMPER (Substitute Β· Combine Β· Adapt Β· Modify Β· Put-to Β· Eliminate Β· Reverse).</li>
164
+ <li><strong>Stuhlfaut &amp; Yoo (2011)</strong> β€” Advertising Creativity 4-D (divergence Β· relevance Β· artistry Β· elaboration).</li>
165
+ <li><strong>Boden (2007)</strong> β€” Novelty Β· Utility Β· Surprise.</li>
166
+ <li><strong>Coats / McKee (2011)</strong> β€” Pixar Story Spine (6 beats).</li>
167
+ <li><strong>Floch (1990)</strong> β€” Four consumption-value axes (practical Β· utopian Β· critical Β· playful).</li>
168
+ </ul>
169
+ </div>
170
+ </details>
171
+
172
+ <details class="foot-details">
173
+ <summary>πŸ§ͺ 15 Collision Rules</summary>
174
+ <div id="rulesTable" class="rules-table"></div>
175
+ </details>
176
+
177
+ <div class="foot-meta">
178
+ Apache-2.0 Β· VIDRAFT Β· μ§€λ‹ˆμ  AI Β·
179
+ <a href="https://huggingface.co/spaces/SeaWolf-AI/aether-ad-genesis" target="_blank" rel="noopener">HF Space</a> Β·
180
+ Docker SDK Β· FastAPI
181
+ </div>
182
+ </footer>
183
+
184
+ <script src="/static/app.js"></script>
185
+ </body>
186
+ </html>
static/styles.css ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #ffffff;
3
+ --surface: #fafbfc;
4
+ --surface-2: #f4f6fa;
5
+ --border: #e6e9f0;
6
+ --border-strong: #cfd4df;
7
+ --text: #0f172a;
8
+ --text-muted: #64748b;
9
+ --text-subtle: #94a3b8;
10
+ --primary: #4f46e5;
11
+ --primary-hover: #4338ca;
12
+ --primary-light: #eef2ff;
13
+ --accent: #a855f7;
14
+ --accent-light: #f3e8ff;
15
+ --success: #10b981;
16
+ --warn: #f59e0b;
17
+ --danger: #ef4444;
18
+ --radius: 14px;
19
+ --radius-sm: 8px;
20
+ --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
21
+ --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06);
22
+ --shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.10);
23
+ }
24
+
25
+ * { box-sizing: border-box; margin: 0; padding: 0; }
26
+ html { scroll-behavior: smooth; }
27
+ body {
28
+ font-family: "Pretendard Variable", Pretendard, "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ line-height: 1.6;
32
+ -webkit-font-smoothing: antialiased;
33
+ -moz-osx-font-smoothing: grayscale;
34
+ }
35
+ a { color: var(--primary); }
36
+
37
+ .container { max-width: 1440px; margin: 0 auto; padding: 0 28px; }
38
+
39
+ /* ── HERO ────────────────────────────────────────────────────── */
40
+ .hero {
41
+ padding: 48px 0 40px;
42
+ background: linear-gradient(180deg, #fafbff 0%, #ffffff 100%);
43
+ border-bottom: 1px solid var(--border);
44
+ position: relative;
45
+ overflow: hidden;
46
+ }
47
+ .hero::before {
48
+ content: ""; position: absolute; top: -240px; right: -180px;
49
+ width: 620px; height: 620px;
50
+ background: radial-gradient(circle, rgba(168, 85, 247, 0.14), transparent 70%);
51
+ pointer-events: none;
52
+ }
53
+ .hero::after {
54
+ content: ""; position: absolute; bottom: -260px; left: -120px;
55
+ width: 540px; height: 540px;
56
+ background: radial-gradient(circle, rgba(79, 70, 229, 0.12), transparent 70%);
57
+ pointer-events: none;
58
+ }
59
+ .hero-inner { position: relative; display: flex; flex-direction: column; gap: 22px; }
60
+ .hero-brand { display: flex; align-items: center; gap: 18px; }
61
+ .hero-logo {
62
+ font-size: 44px;
63
+ background: linear-gradient(135deg, var(--primary), var(--accent));
64
+ width: 76px; height: 76px;
65
+ display: grid; place-items: center;
66
+ border-radius: 20px;
67
+ color: white;
68
+ box-shadow: 0 10px 28px rgba(79, 70, 229, 0.35);
69
+ }
70
+ .hero-title {
71
+ font-size: 38px; font-weight: 800;
72
+ letter-spacing: -0.03em; line-height: 1.08;
73
+ }
74
+ .hero-title .grad {
75
+ background: linear-gradient(135deg, var(--primary), var(--accent));
76
+ -webkit-background-clip: text; background-clip: text; color: transparent;
77
+ }
78
+ .hero-subtitle { font-size: 15px; color: var(--text-muted); margin-top: 6px; }
79
+ .hero-pillars { display: flex; flex-wrap: wrap; gap: 8px; }
80
+ .pill {
81
+ padding: 7px 14px;
82
+ border-radius: 999px;
83
+ background: white;
84
+ color: var(--primary);
85
+ font-size: 12px; font-weight: 600;
86
+ border: 1px solid rgba(79, 70, 229, 0.22);
87
+ box-shadow: var(--shadow-sm);
88
+ }
89
+
90
+ /* ── MAIN LAYOUT ─────────────────────────────────────────────── */
91
+ .main {
92
+ display: grid;
93
+ grid-template-columns: 390px 1fr;
94
+ gap: 32px;
95
+ padding-top: 32px;
96
+ padding-bottom: 56px;
97
+ }
98
+ @media (max-width: 1024px) {
99
+ .main { grid-template-columns: 1fr; }
100
+ .sidebar { position: static !important; max-height: none !important; }
101
+ }
102
+
103
+ .sidebar {
104
+ display: flex; flex-direction: column; gap: 16px;
105
+ position: sticky; top: 20px; align-self: start;
106
+ max-height: calc(100vh - 40px);
107
+ overflow-y: auto;
108
+ padding-right: 6px;
109
+ }
110
+ .sidebar::-webkit-scrollbar { width: 6px; }
111
+ .sidebar::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
112
+
113
+ .card, .card-flat {
114
+ background: white;
115
+ border: 1px solid var(--border);
116
+ border-radius: var(--radius);
117
+ padding: 22px;
118
+ box-shadow: var(--shadow-sm);
119
+ }
120
+ .card-flat { padding: 20px 22px; }
121
+
122
+ .card-header { margin-bottom: 16px; }
123
+ .card-header h2 {
124
+ font-size: 12px; font-weight: 700;
125
+ display: flex; align-items: center; gap: 10px;
126
+ text-transform: uppercase; letter-spacing: 0.08em;
127
+ color: var(--text);
128
+ }
129
+ .card-header h2 small {
130
+ font-size: 11px; color: var(--text-muted);
131
+ font-weight: 500; text-transform: none; letter-spacing: 0;
132
+ margin-left: auto;
133
+ }
134
+ .step {
135
+ width: 24px; height: 24px;
136
+ background: linear-gradient(135deg, var(--primary), var(--accent));
137
+ color: white; border-radius: 7px;
138
+ display: grid; place-items: center;
139
+ font-size: 11px; font-weight: 700;
140
+ box-shadow: 0 2px 6px rgba(79, 70, 229, 0.28);
141
+ }
142
+
143
+ .field { margin-bottom: 14px; }
144
+ .field:last-child { margin-bottom: 0; }
145
+ .field label {
146
+ display: block; font-size: 12px; font-weight: 600;
147
+ color: var(--text-muted); margin-bottom: 7px;
148
+ }
149
+ .field label small { font-weight: 400; color: var(--text-subtle); }
150
+ .field output {
151
+ font-weight: 700; color: var(--primary);
152
+ font-variant-numeric: tabular-nums;
153
+ }
154
+
155
+ select, input[type="text"], input[type="number"], textarea {
156
+ width: 100%; padding: 10px 12px;
157
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
158
+ background: white; font: inherit; font-size: 14px; color: var(--text);
159
+ transition: border-color 0.15s, box-shadow 0.15s;
160
+ font-family: inherit;
161
+ }
162
+ textarea { resize: vertical; min-height: 48px; }
163
+ select:focus, input:focus, textarea:focus {
164
+ outline: none; border-color: var(--primary);
165
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14);
166
+ }
167
+ input[type="range"] { padding: 0; width: 100%; accent-color: var(--primary); }
168
+
169
+ .radio-group { display: flex; gap: 8px; }
170
+ .radio-chip { flex: 1; display: block; position: relative; cursor: pointer; }
171
+ .radio-chip input { position: absolute; opacity: 0; pointer-events: none; }
172
+ .radio-chip span {
173
+ display: block; padding: 10px 12px; text-align: center;
174
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
175
+ font-size: 13px; font-weight: 600;
176
+ transition: all 0.15s; background: white;
177
+ }
178
+ .radio-chip input:checked + span {
179
+ background: linear-gradient(135deg, var(--primary), var(--accent));
180
+ color: white; border-color: transparent;
181
+ box-shadow: 0 4px 12px rgba(79, 70, 229, 0.28);
182
+ }
183
+
184
+ .meta {
185
+ font-size: 12px; color: var(--text-muted);
186
+ margin-top: 10px; line-height: 1.55;
187
+ }
188
+
189
+ /* ── GREIMAS SQUARE ──────────────────────────────────────────── */
190
+ .greimas {
191
+ display: grid; grid-template-columns: 1fr 1fr;
192
+ gap: 8px; margin: 12px 0 2px;
193
+ }
194
+ .g-cell {
195
+ padding: 11px 12px; border-radius: 10px;
196
+ border: 1px solid var(--border); background: var(--surface);
197
+ min-height: 70px;
198
+ transition: transform 0.15s, box-shadow 0.15s;
199
+ }
200
+ .g-cell.g-s1 {
201
+ border-color: rgba(79, 70, 229, 0.28);
202
+ background: linear-gradient(135deg, var(--primary-light), #ffffff);
203
+ }
204
+ .g-cell.g-s2 {
205
+ border-color: rgba(168, 85, 247, 0.28);
206
+ background: linear-gradient(135deg, var(--accent-light), #ffffff);
207
+ }
208
+ .g-cell.g-ns1 { background: linear-gradient(135deg, #f8fafc, #ffffff); }
209
+ .g-cell.g-ns2 { background: linear-gradient(135deg, #f8fafc, #ffffff); }
210
+ .g-label {
211
+ display: block; font-size: 10px; font-weight: 700;
212
+ color: var(--text-muted); margin-bottom: 5px;
213
+ font-family: "JetBrains Mono", monospace;
214
+ text-transform: uppercase; letter-spacing: 0.05em;
215
+ }
216
+ .g-value {
217
+ display: block; font-size: 12.5px; font-weight: 500;
218
+ color: var(--text); line-height: 1.35;
219
+ }
220
+
221
+ /* ── BUTTONS ─────────────────────────────────────────────────── */
222
+ .btn-primary {
223
+ width: 100%; padding: 13px 16px; margin-top: 8px;
224
+ border: none; border-radius: var(--radius-sm);
225
+ background: linear-gradient(135deg, var(--primary), var(--accent));
226
+ color: white; font-weight: 700; font-size: 14.5px;
227
+ cursor: pointer;
228
+ transition: transform 0.1s, box-shadow 0.15s;
229
+ display: flex; align-items: center; justify-content: center; gap: 10px;
230
+ box-shadow: 0 6px 18px rgba(79, 70, 229, 0.28);
231
+ font-family: inherit;
232
+ }
233
+ .btn-primary:hover:not(:disabled) {
234
+ transform: translateY(-1px);
235
+ box-shadow: 0 8px 24px rgba(79, 70, 229, 0.38);
236
+ }
237
+ .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
238
+ .btn-spinner {
239
+ width: 16px; height: 16px;
240
+ border: 2px solid rgba(255, 255, 255, 0.35);
241
+ border-top-color: white; border-radius: 50%;
242
+ animation: spin 0.7s linear infinite;
243
+ }
244
+ @keyframes spin { to { transform: rotate(360deg); } }
245
+
246
+ .err {
247
+ margin-top: 12px; padding: 10px 12px;
248
+ background: #fef2f2; border: 1px solid #fecaca;
249
+ border-radius: var(--radius-sm);
250
+ color: var(--danger); font-size: 13px; line-height: 1.5;
251
+ }
252
+
253
+ /* ── RESULTS ─────────────────────────────────────────────────── */
254
+ .results { display: flex; flex-direction: column; gap: 16px; }
255
+
256
+ /* Pipeline */
257
+ .pipeline-title {
258
+ font-size: 11px; font-weight: 700;
259
+ color: var(--text-muted);
260
+ text-transform: uppercase; letter-spacing: 0.08em;
261
+ margin-bottom: 14px;
262
+ }
263
+ .pipeline-stages {
264
+ display: flex; justify-content: space-between; align-items: center;
265
+ position: relative;
266
+ }
267
+ .pipeline-stages::before {
268
+ content: ""; position: absolute;
269
+ top: 14px; left: 4%; right: 4%;
270
+ height: 2px; background: var(--border);
271
+ z-index: 0;
272
+ }
273
+ .stage {
274
+ flex: 1; display: flex; flex-direction: column; align-items: center;
275
+ position: relative; z-index: 1; gap: 8px;
276
+ }
277
+ .stage-dot {
278
+ width: 30px; height: 30px;
279
+ background: white; border: 2px solid var(--border);
280
+ border-radius: 50%; position: relative;
281
+ transition: all 0.3s;
282
+ }
283
+ .stage-label {
284
+ font-size: 11px; font-weight: 700; color: var(--text-muted);
285
+ text-transform: uppercase; letter-spacing: 0.04em;
286
+ }
287
+ .stage.active .stage-dot {
288
+ background: linear-gradient(135deg, var(--primary), var(--accent));
289
+ border-color: transparent;
290
+ box-shadow: 0 0 0 6px rgba(79, 70, 229, 0.18);
291
+ animation: pulse 1.2s ease-in-out infinite;
292
+ }
293
+ .stage.done .stage-dot {
294
+ background: linear-gradient(135deg, var(--primary), var(--accent));
295
+ border-color: transparent;
296
+ }
297
+ .stage.done .stage-dot::after {
298
+ content: "βœ“"; position: absolute; inset: 0;
299
+ display: grid; place-items: center;
300
+ color: white; font-size: 14px; font-weight: 700;
301
+ }
302
+ .stage.active .stage-label, .stage.done .stage-label { color: var(--primary); }
303
+ @keyframes pulse {
304
+ 0%, 100% { box-shadow: 0 0 0 6px rgba(79, 70, 229, 0.18); }
305
+ 50% { box-shadow: 0 0 0 11px rgba(79, 70, 229, 0.08); }
306
+ }
307
+
308
+ /* Empty state */
309
+ .empty {
310
+ text-align: center; padding: 72px 28px;
311
+ background: white;
312
+ border: 2px dashed var(--border);
313
+ border-radius: var(--radius);
314
+ }
315
+ .empty-icon { font-size: 60px; margin-bottom: 14px; }
316
+ .empty h3 { font-size: 20px; font-weight: 700; margin-bottom: 8px; }
317
+ .empty p { color: var(--text-muted); font-size: 14px; line-height: 1.7; }
318
+
319
+ /* Summary bar */
320
+ .summary {
321
+ display: grid; grid-template-columns: repeat(4, 1fr);
322
+ gap: 14px;
323
+ background: linear-gradient(135deg, var(--primary-light), var(--accent-light));
324
+ border: 1px solid rgba(79, 70, 229, 0.18);
325
+ border-radius: var(--radius);
326
+ padding: 20px 22px;
327
+ }
328
+ .summary-metric { display: flex; flex-direction: column; }
329
+ .summary-label {
330
+ font-size: 11px; font-weight: 700;
331
+ color: var(--text-muted);
332
+ text-transform: uppercase; letter-spacing: 0.06em;
333
+ margin-bottom: 6px;
334
+ }
335
+ .summary-value {
336
+ font-size: 24px; font-weight: 800;
337
+ color: var(--text); letter-spacing: -0.02em;
338
+ font-variant-numeric: tabular-nums;
339
+ }
340
+ .summary-value-sm { font-size: 15px; font-weight: 700; }
341
+
342
+ /* ── SEED CARD ───────────────────────────────────────────────── */
343
+ .seeds { display: flex; flex-direction: column; gap: 18px; }
344
+
345
+ .seed-card {
346
+ background: white;
347
+ border: 1px solid var(--border);
348
+ border-radius: var(--radius);
349
+ padding: 22px;
350
+ box-shadow: var(--shadow-sm);
351
+ transition: box-shadow 0.2s, transform 0.2s;
352
+ overflow: hidden;
353
+ position: relative;
354
+ }
355
+ .seed-card::before {
356
+ content: ""; position: absolute;
357
+ top: 0; left: 0; right: 0; height: 3px;
358
+ background: linear-gradient(90deg, var(--primary), var(--accent));
359
+ opacity: 0; transition: opacity 0.2s;
360
+ }
361
+ .seed-card:hover {
362
+ box-shadow: var(--shadow-md);
363
+ }
364
+ .seed-card:hover::before { opacity: 1; }
365
+
366
+ .seed-head {
367
+ display: flex; align-items: flex-start; gap: 18px;
368
+ margin-bottom: 16px;
369
+ }
370
+ .seed-rank-block { min-width: 54px; }
371
+ .seed-rank-label {
372
+ font-size: 10px; color: var(--text-muted);
373
+ text-transform: uppercase; font-weight: 700;
374
+ letter-spacing: 0.06em; display: block; margin-bottom: 2px;
375
+ }
376
+ .seed-rank {
377
+ font-size: 32px; font-weight: 900;
378
+ background: linear-gradient(135deg, var(--primary), var(--accent));
379
+ -webkit-background-clip: text; background-clip: text; color: transparent;
380
+ font-variant-numeric: tabular-nums;
381
+ letter-spacing: -0.04em; line-height: 1;
382
+ }
383
+ .seed-info { flex: 1; min-width: 0; }
384
+ .seed-id {
385
+ font-family: "JetBrains Mono", monospace;
386
+ font-size: 11.5px; color: var(--text-muted);
387
+ background: var(--surface-2); padding: 4px 9px;
388
+ border-radius: 5px; display: inline-block;
389
+ margin-bottom: 9px;
390
+ }
391
+ .seed-rules { display: flex; flex-wrap: wrap; gap: 6px; }
392
+ .rule-pill {
393
+ font-family: "JetBrains Mono", monospace;
394
+ font-size: 10.5px; font-weight: 600;
395
+ padding: 4px 9px; border-radius: 5px;
396
+ background: var(--accent-light); color: #7e22ce;
397
+ border: 1px solid rgba(168, 85, 247, 0.28);
398
+ }
399
+
400
+ .final-gauge {
401
+ width: 86px; height: 86px;
402
+ position: relative; flex-shrink: 0;
403
+ }
404
+ .final-gauge svg { transform: rotate(-90deg); width: 100%; height: 100%; }
405
+ .final-gauge-track { fill: none; stroke: var(--surface-2); stroke-width: 8; }
406
+ .final-gauge-fill {
407
+ fill: none; stroke-width: 8; stroke-linecap: round;
408
+ transition: stroke-dashoffset 0.8s ease-out;
409
+ }
410
+ .final-gauge-text {
411
+ position: absolute; inset: 0;
412
+ display: grid; place-items: center; text-align: center;
413
+ }
414
+ .final-gauge-value {
415
+ font-size: 19px; font-weight: 800;
416
+ color: var(--text); line-height: 1;
417
+ font-variant-numeric: tabular-nums;
418
+ }
419
+ .final-gauge-label {
420
+ font-size: 9px; font-weight: 700;
421
+ color: var(--text-muted);
422
+ text-transform: uppercase; letter-spacing: 0.08em;
423
+ margin-top: 3px;
424
+ }
425
+
426
+ .seed-concept {
427
+ font-size: 14.5px; line-height: 1.75;
428
+ color: var(--text);
429
+ padding: 14px 16px;
430
+ background: linear-gradient(180deg, var(--surface) 0%, #ffffff 100%);
431
+ border-left: 3px solid var(--primary);
432
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
433
+ margin-bottom: 18px;
434
+ word-break: keep-all;
435
+ }
436
+
437
+ .seed-section-title {
438
+ font-size: 10.5px; font-weight: 700;
439
+ color: var(--text-muted);
440
+ text-transform: uppercase; letter-spacing: 0.08em;
441
+ margin: 18px 0 10px;
442
+ }
443
+
444
+ /* ── TIMELINE ────────────────────────────────────────────────── */
445
+ .timeline {
446
+ position: relative;
447
+ background: var(--surface);
448
+ border-radius: var(--radius-sm);
449
+ padding: 12px;
450
+ }
451
+ .timeline-bar {
452
+ height: 42px; display: flex;
453
+ border-radius: 8px; overflow: hidden;
454
+ box-shadow: inset 0 1px 2px rgba(0,0,0,0.05);
455
+ }
456
+ .beat-seg {
457
+ position: relative;
458
+ display: flex; flex-direction: column; justify-content: center;
459
+ padding: 0 10px; overflow: hidden; white-space: nowrap;
460
+ transition: filter 0.15s;
461
+ cursor: default;
462
+ }
463
+ .beat-seg:hover { filter: brightness(0.94); }
464
+ .beat-seg-name {
465
+ font-size: 10px; font-weight: 700;
466
+ text-transform: uppercase; letter-spacing: 0.04em;
467
+ color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.18);
468
+ overflow: hidden; text-overflow: ellipsis;
469
+ }
470
+ .beat-seg-time {
471
+ font-size: 9.5px;
472
+ color: rgba(255, 255, 255, 0.88);
473
+ font-family: "JetBrains Mono", monospace;
474
+ }
475
+ .timeline-scale {
476
+ margin-top: 8px; position: relative; height: 20px;
477
+ }
478
+ .timeline-tick {
479
+ position: absolute; top: 0;
480
+ transform: translateX(-50%);
481
+ font-size: 10px; color: var(--text-muted);
482
+ font-family: "JetBrains Mono", monospace;
483
+ font-weight: 600;
484
+ }
485
+ .timeline-tick::before {
486
+ content: ""; display: block;
487
+ width: 1px; height: 5px; background: var(--border-strong);
488
+ margin: 0 auto 3px;
489
+ }
490
+
491
+ .beats-detail {
492
+ margin-top: 14px;
493
+ display: flex; flex-direction: column; gap: 2px;
494
+ }
495
+ .beat-row {
496
+ display: grid; grid-template-columns: 72px 130px 1fr;
497
+ gap: 12px; padding: 8px 0;
498
+ border-bottom: 1px dashed var(--border);
499
+ align-items: start; font-size: 12.5px;
500
+ }
501
+ .beat-row:last-child { border-bottom: 0; }
502
+ .beat-row-time {
503
+ font-family: "JetBrains Mono", monospace;
504
+ color: var(--text-muted); font-weight: 600;
505
+ font-size: 11.5px;
506
+ }
507
+ .beat-row-name {
508
+ font-weight: 700; color: var(--primary);
509
+ font-size: 11px;
510
+ text-transform: uppercase; letter-spacing: 0.03em;
511
+ }
512
+ .beat-row-content { color: var(--text); line-height: 1.6; }
513
+
514
+ /* ── SCORES ──────────────────────────────────────────────────── */
515
+ .scores-section {
516
+ display: grid; grid-template-columns: 220px 1fr;
517
+ gap: 22px; margin-top: 16px; padding-top: 18px;
518
+ border-top: 1px solid var(--border);
519
+ }
520
+ .radar-wrap { position: relative; height: 220px; }
521
+ .scores-table {
522
+ display: grid; grid-template-columns: 1fr 1fr;
523
+ gap: 8px; align-content: start;
524
+ }
525
+ .score-item {
526
+ padding: 9px 12px; background: var(--surface); border-radius: 7px;
527
+ display: flex; justify-content: space-between; align-items: center;
528
+ }
529
+ .score-item-label {
530
+ font-size: 10.5px; font-weight: 700;
531
+ color: var(--text-muted);
532
+ text-transform: uppercase; letter-spacing: 0.04em;
533
+ }
534
+ .score-item-value {
535
+ font-family: "JetBrains Mono", monospace;
536
+ font-size: 13.5px; font-weight: 700;
537
+ color: var(--text);
538
+ font-variant-numeric: tabular-nums;
539
+ }
540
+
541
+ /* Beat color palette (6 beats, indigo β†’ rose) */
542
+ .beat-color-0 { background: #6366f1; }
543
+ .beat-color-1 { background: #8b5cf6; }
544
+ .beat-color-2 { background: #a855f7; }
545
+ .beat-color-3 { background: #d946ef; }
546
+ .beat-color-4 { background: #ec4899; }
547
+ .beat-color-5 { background: #f43f5e; }
548
+
549
+ /* ── FOOTER ──────────────────────────────────────────────────── */
550
+ .footer {
551
+ border-top: 1px solid var(--border);
552
+ padding-top: 28px; padding-bottom: 44px;
553
+ display: flex; flex-direction: column; gap: 14px;
554
+ }
555
+ .foot-details {
556
+ border: 1px solid var(--border);
557
+ border-radius: var(--radius-sm);
558
+ padding: 14px 18px;
559
+ background: var(--surface);
560
+ }
561
+ .foot-details summary {
562
+ cursor: pointer; font-weight: 700;
563
+ color: var(--text); font-size: 14px;
564
+ }
565
+ .foot-details[open] summary { margin-bottom: 14px; }
566
+ .foot-body ul {
567
+ list-style: none;
568
+ display: flex; flex-direction: column; gap: 10px;
569
+ font-size: 13px; color: var(--text-muted);
570
+ padding: 4px 0;
571
+ }
572
+ .foot-body li strong { color: var(--text); font-weight: 700; }
573
+
574
+ .rules-table { display: grid; gap: 2px; font-size: 12px; }
575
+ .rule-row {
576
+ display: grid;
577
+ grid-template-columns: 230px 1.2fr 1.6fr 200px;
578
+ padding: 9px 8px;
579
+ border-bottom: 1px dashed var(--border);
580
+ gap: 12px; align-items: start;
581
+ }
582
+ .rule-row:last-child { border-bottom: 0; }
583
+ .rule-row.header {
584
+ font-weight: 700; color: var(--text-muted);
585
+ text-transform: uppercase; font-size: 10px;
586
+ letter-spacing: 0.06em;
587
+ border-bottom: 1px solid var(--border-strong);
588
+ }
589
+ .rule-id {
590
+ font-family: "JetBrains Mono", monospace;
591
+ font-size: 11px; font-weight: 700;
592
+ color: var(--primary);
593
+ }
594
+ .rule-anchor {
595
+ font-size: 10.5px; color: var(--text-subtle);
596
+ font-family: "JetBrains Mono", monospace;
597
+ }
598
+
599
+ .foot-meta {
600
+ font-size: 12px; color: var(--text-subtle);
601
+ text-align: center; padding: 14px 0;
602
+ }
603
+ .foot-meta a { color: var(--primary); text-decoration: none; font-weight: 600; }
604
+
605
+ /* ── RESPONSIVE ──────────────────────────────────────────────── */
606
+ @media (max-width: 820px) {
607
+ .summary { grid-template-columns: repeat(2, 1fr); }
608
+ .scores-section { grid-template-columns: 1fr; }
609
+ .rule-row { grid-template-columns: 1fr; gap: 4px; }
610
+ .hero-title { font-size: 30px; }
611
+ .beats-detail .beat-row { grid-template-columns: 70px 1fr; }
612
+ .beat-row-name { grid-column: 2; }
613
+ .beat-row-content { grid-column: 1 / 3; padding-top: 2px; }
614
+ }