kyu823 commited on
Commit
08ab15f
·
verified ·
1 Parent(s): 4ce54cf

Initial silicon sampling lab deploy

Browse files
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ node_modules/
5
+ __pycache__/
6
+ *.pyc
7
+ static/immersive/
8
+ runs/
9
+ data/nemotron_personas_korea/
10
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-bookworm-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1
4
+ ENV PORT=7860
5
+ ENV HOST=0.0.0.0
6
+ ENV SILICON_LLM_MAX_AGENTS=3000
7
+
8
+ RUN apt-get update \
9
+ && apt-get install -y --no-install-recommends python3 python3-pip git curl \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /app
13
+ COPY package.json package-lock.json* ./
14
+ RUN npm ci
15
+
16
+ COPY requirements.txt ./
17
+ RUN pip3 install --break-system-packages --no-cache-dir -r requirements.txt
18
+
19
+ COPY . .
20
+ RUN npm run build
21
+
22
+ EXPOSE 7860
23
+ CMD ["python3", "server.py", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,53 @@
1
  ---
2
  title: Silicon Sampling Lab
3
- emoji: 🌖
4
  colorFrom: blue
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Silicon Sampling Lab
3
+ emoji: 📊
4
  colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # Silicon Sampling Lab
11
+
12
+ Korean silicon-sampling web app for running persona-agent survey simulations with OpenAI API calls.
13
+
14
+ This repository contains only the silicon sampling app:
15
+
16
+ - React/Vite frontend
17
+ - Python standard-library HTTP server
18
+ - LLM-backed silicon sampling runtime
19
+ - local Nemotron-Personas-Korea dataset helpers
20
+
21
+ It does not include the broader multi-framework social simulation UI.
22
+
23
+ ## Local Run
24
+
25
+ ```bash
26
+ npm ci
27
+ npm run build
28
+ python3 -m pip install -r requirements.txt
29
+ OPENAI_API_KEY=... python3 server.py --host 127.0.0.1 --port 8765
30
+ ```
31
+
32
+ Open:
33
+
34
+ ```text
35
+ http://127.0.0.1:8765/silicon/
36
+ ```
37
+
38
+ ## Hugging Face Spaces
39
+
40
+ This repo is configured as a Docker Space. Set `OPENAI_API_KEY` as a Space secret, not as a committed file.
41
+
42
+ The app listens on port `7860`.
43
+
44
+ ## Optional Nemotron Dataset
45
+
46
+ The app works with fallback distributions if the local dataset is not present. To enable local Nemotron-Personas-Korea metadata and sampling:
47
+
48
+ ```bash
49
+ python3 scripts/download_nemotron_personas_korea.py
50
+ python3 scripts/build_nemotron_persona_index.py
51
+ ```
52
+
53
+ Downloaded dataset files are intentionally ignored by git.
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { SiliconSamplingApp } from "./silicon/SiliconSamplingApp";
4
+ import "./styles.css";
5
+
6
+ createRoot(document.getElementById("root") as HTMLElement).render(
7
+ <React.StrictMode>
8
+ <SiliconSamplingApp />
9
+ </React.StrictMode>,
10
+ );
frontend/src/silicon/SiliconCharts.tsx ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { BarChart } from "echarts/charts";
3
+ import { GridComponent, TooltipComponent } from "echarts/components";
4
+ import { init, use } from "echarts/core";
5
+ import { CanvasRenderer } from "echarts/renderers";
6
+ import { AGE_BANDS, GENDERS } from "./data";
7
+ import { resultBreakdown } from "./simulate";
8
+ import type { PersonaDimensionId, SiliconResult, SyntheticRespondent } from "./types";
9
+
10
+ use([BarChart, GridComponent, TooltipComponent, CanvasRenderer]);
11
+
12
+ interface SiliconChartsProps {
13
+ result: SiliconResult;
14
+ }
15
+
16
+ type Breakdown = "overall" | "gender" | "age" | "region" | PersonaDimensionId;
17
+ type MetricMode = "mean" | "positive";
18
+
19
+ const BREAKDOWN_MODES: Array<{ id: Breakdown; label: string }> = [
20
+ { id: "overall", label: "전체" },
21
+ { id: "gender", label: "성별" },
22
+ { id: "age", label: "연령대" },
23
+ { id: "region", label: "지역" },
24
+ { id: "occupation", label: "직업" },
25
+ { id: "education", label: "학력" },
26
+ { id: "housing", label: "주거" },
27
+ { id: "marital", label: "혼인" },
28
+ { id: "family", label: "가구" },
29
+ ];
30
+
31
+ export function SiliconCharts({ result }: SiliconChartsProps) {
32
+ const [selectedQuestionId, setSelectedQuestionId] = useState(result.config.questions[0]?.id || "");
33
+ const [breakdown, setBreakdown] = useState<Breakdown>("overall");
34
+ const [metricMode, setMetricMode] = useState<MetricMode>("mean");
35
+ const selectedQuestion = result.config.questions.find((question) => question.id === selectedQuestionId) || result.config.questions[0];
36
+ const selectedStat = result.questionStats.find((stat) => stat.questionId === selectedQuestion?.id);
37
+ const isLikert = selectedQuestion?.kind === "likert";
38
+ const breakdownRows = useMemo(
39
+ () => isLikert && breakdown !== "overall" ? resultBreakdown(result, selectedQuestion.id, breakdown) : [],
40
+ [breakdown, isLikert, result, selectedQuestion?.id],
41
+ );
42
+
43
+ useEffect(() => {
44
+ if (!result.config.questions.some((question) => question.id === selectedQuestionId)) {
45
+ setSelectedQuestionId(result.config.questions[0]?.id || "");
46
+ }
47
+ }, [result, selectedQuestionId]);
48
+
49
+ if (!selectedQuestion) return null;
50
+
51
+ return (
52
+ <section className="silicon-explorer" data-testid="silicon-charts">
53
+ <div className="explorer-toolbar">
54
+ <div>
55
+ <span>question</span>
56
+ <strong>문항별 결과 탐색</strong>
57
+ </div>
58
+ <div className="result-mode-buttons">
59
+ {BREAKDOWN_MODES.map((mode) => (
60
+ <button key={mode.id} type="button" className={breakdown === mode.id ? "active" : ""} onClick={() => setBreakdown(mode.id)}>
61
+ {mode.label}
62
+ </button>
63
+ ))}
64
+ </div>
65
+ </div>
66
+
67
+ <div className="question-tabs">
68
+ {result.config.questions.map((question) => (
69
+ <button
70
+ key={question.id}
71
+ type="button"
72
+ className={[
73
+ selectedQuestion.id === question.id ? "active" : "",
74
+ question.kind === "likert" ? "kind-likert" : "kind-open",
75
+ ].filter(Boolean).join(" ")}
76
+ onClick={() => {
77
+ setSelectedQuestionId(question.id);
78
+ if (question.kind === "open") setBreakdown("overall");
79
+ }}
80
+ >
81
+ <span>{question.kind === "likert" ? `${question.scale}점 Likert` : "Open-ended"}</span>
82
+ <strong>{question.title}</strong>
83
+ </button>
84
+ ))}
85
+ </div>
86
+
87
+ {isLikert ? (
88
+ <>
89
+ <div className="metric-tabs">
90
+ <button type="button" className={metricMode === "mean" ? "active" : ""} onClick={() => setMetricMode("mean")}>평균</button>
91
+ <button type="button" className={metricMode === "positive" ? "active" : ""} onClick={() => setMetricMode("positive")}>긍정률</button>
92
+ </div>
93
+ <div className="silicon-chart-grid single">
94
+ {breakdown === "overall" ? (
95
+ <ChartCard
96
+ title="응답 분포"
97
+ subtitle={selectedQuestion.title}
98
+ option={{
99
+ color: ["#5b6ee1"],
100
+ tooltip: tooltip(),
101
+ grid: grid(),
102
+ xAxis: { type: "category", data: selectedStat?.distribution?.map((item) => `${item.value}`) || [], axisLabel: axisLabel() },
103
+ yAxis: { type: "value", axisLabel: axisLabel(), splitLine: splitLine() },
104
+ series: [{
105
+ type: "bar",
106
+ data: selectedStat?.distribution?.map((item) => item.count) || [],
107
+ barWidth: "52%",
108
+ itemStyle: { borderRadius: [8, 8, 0, 0] },
109
+ }],
110
+ }}
111
+ />
112
+ ) : (
113
+ <ChartCard
114
+ title={`${modeLabel(breakdown)} ${metricMode === "mean" ? "평균" : "긍정률"}`}
115
+ subtitle={selectedQuestion.title}
116
+ option={breakdownOption(breakdownRows, metricMode)}
117
+ />
118
+ )}
119
+ <MetricPanel
120
+ mean={selectedStat?.mean}
121
+ positiveShare={selectedStat?.positiveShare}
122
+ count={result.likertAnswers.filter((answer) => answer.questionId === selectedQuestion.id).length}
123
+ scale={selectedQuestion.scale || 5}
124
+ />
125
+ <ResponseTable result={result} questionId={selectedQuestion.id} />
126
+ </div>
127
+ </>
128
+ ) : (
129
+ <div className="silicon-chart-grid open-only">
130
+ <OpenAnswerPanel result={result} questionId={selectedQuestion.id} />
131
+ <ResponseTable result={result} questionId={selectedQuestion.id} />
132
+ </div>
133
+ )}
134
+ </section>
135
+ );
136
+ }
137
+
138
+ function MetricPanel({ mean, positiveShare, count, scale }: { mean?: number; positiveShare?: number; count: number; scale: number }) {
139
+ return (
140
+ <section className="silicon-chart-card metric-card">
141
+ <header>
142
+ <span>selected question</span>
143
+ <strong>요약 지표</strong>
144
+ </header>
145
+ <div className="metric-card-grid">
146
+ <MetricItem label="응답 수" value={`${count.toLocaleString()}개`} />
147
+ <MetricItem label="척도" value={`${scale}점`} />
148
+ <MetricItem label="평균" value={formatNumber(mean)} />
149
+ <MetricItem label="긍정률" value={formatPercent(positiveShare)} />
150
+ </div>
151
+ </section>
152
+ );
153
+ }
154
+
155
+ function OpenAnswerPanel({ result, questionId }: { result: SiliconResult; questionId: string }) {
156
+ const answers = result.openAnswers.filter((answer) => answer.questionId === questionId);
157
+ return (
158
+ <section className="silicon-open-panel" data-testid="silicon-open-answers">
159
+ <header>
160
+ <span>open-ended answers</span>
161
+ <strong>실제 LLM 답안</strong>
162
+ </header>
163
+ <div className="silicon-answer-list">
164
+ {answers.slice(0, 18).map((answer) => {
165
+ const respondent = result.respondents.find((item) => item.id === answer.respondentId);
166
+ return (
167
+ <article key={`${answer.questionId}-${answer.respondentId}`}>
168
+ <span>{respondent?.id} · {respondent?.locationLabel} · {labelOf(AGE_BANDS, respondent?.age)} · {labelOf(GENDERS, respondent?.gender)} · {personaSummary(respondent)}</span>
169
+ <p>{answer.text}</p>
170
+ </article>
171
+ );
172
+ })}
173
+ </div>
174
+ </section>
175
+ );
176
+ }
177
+
178
+ function ChartCard({ title, subtitle, option }: { title: string; subtitle: string; option: Record<string, unknown> }) {
179
+ const ref = useRef<HTMLDivElement | null>(null);
180
+ useEffect(() => {
181
+ if (!ref.current) return undefined;
182
+ const chart = init(ref.current, undefined, { renderer: "canvas" });
183
+ chart.setOption({ backgroundColor: "transparent", ...option });
184
+ const observer = new ResizeObserver(() => chart.resize());
185
+ observer.observe(ref.current);
186
+ return () => {
187
+ observer.disconnect();
188
+ chart.dispose();
189
+ };
190
+ }, [option]);
191
+ return (
192
+ <section className="silicon-chart-card">
193
+ <header>
194
+ <span>{subtitle}</span>
195
+ <strong>{title}</strong>
196
+ </header>
197
+ <div ref={ref} className="silicon-chart" />
198
+ </section>
199
+ );
200
+ }
201
+
202
+ function ResponseTable({ result, questionId }: { result: SiliconResult; questionId: string }) {
203
+ const question = result.config.questions.find((item) => item.id === questionId);
204
+ if (!question) return null;
205
+ const likertByRespondent = new Map(result.likertAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.value]));
206
+ const openByRespondent = new Map(result.openAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.text]));
207
+ const rows = result.respondents
208
+ .map((respondent) => ({
209
+ respondent,
210
+ value: question.kind === "likert" ? likertByRespondent.get(respondent.id) : openByRespondent.get(respondent.id),
211
+ }))
212
+ .filter((row) => row.value !== undefined);
213
+ return (
214
+ <section className="silicon-response-table-card" data-testid="silicon-response-table">
215
+ <header>
216
+ <span>response table</span>
217
+ <strong>문항별 개별 응답과 persona 속성</strong>
218
+ </header>
219
+ <div className="silicon-response-table-scroll">
220
+ <table className="silicon-response-table">
221
+ <thead>
222
+ <tr>
223
+ <th>응답자</th>
224
+ <th>인구통계</th>
225
+ <th>직업</th>
226
+ <th>학력</th>
227
+ <th>주거</th>
228
+ <th>혼인</th>
229
+ <th>가구</th>
230
+ <th>{question.kind === "likert" ? "점수" : "답변"}</th>
231
+ </tr>
232
+ </thead>
233
+ <tbody>
234
+ {rows.map(({ respondent, value }) => (
235
+ <tr key={`${questionId}-${respondent.id}`}>
236
+ <td>{respondent.id}</td>
237
+ <td>{respondent.locationLabel} · {labelOf(AGE_BANDS, respondent.age)} · {labelOf(GENDERS, respondent.gender)}</td>
238
+ <td>{respondent.personaLabels.occupation || "-"}</td>
239
+ <td>{respondent.personaLabels.education || "-"}</td>
240
+ <td>{respondent.personaLabels.housing || "-"}</td>
241
+ <td>{respondent.personaLabels.marital || "-"}</td>
242
+ <td>{respondent.personaLabels.family || "-"}</td>
243
+ <td>{question.kind === "likert" ? `${value}점` : String(value)}</td>
244
+ </tr>
245
+ ))}
246
+ </tbody>
247
+ </table>
248
+ </div>
249
+ </section>
250
+ );
251
+ }
252
+
253
+ function MetricItem({ label, value }: { label: string; value: string }) {
254
+ return (
255
+ <div className="metric-card-item">
256
+ <span>{label}</span>
257
+ <strong>{value}</strong>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ function breakdownOption(rows: ReturnType<typeof resultBreakdown>, metricMode: MetricMode) {
263
+ const isPositive = metricMode === "positive";
264
+ return {
265
+ color: [isPositive ? "#f08a6c" : "#5b6ee1"],
266
+ tooltip: tooltip((value) => isPositive ? `${Math.round(Number(value) * 100)}%` : `${Number(value).toFixed(2)}`),
267
+ grid: { ...grid(), left: 68 },
268
+ xAxis: {
269
+ type: "value",
270
+ max: isPositive ? 1 : undefined,
271
+ axisLabel: { ...axisLabel(), formatter: isPositive ? (value: number) => `${Math.round(value * 100)}%` : undefined },
272
+ splitLine: splitLine(),
273
+ },
274
+ yAxis: { type: "category", data: rows.map((item) => item.label), axisLabel: axisLabel() },
275
+ series: [{
276
+ type: "bar",
277
+ data: rows.map((item) => Number((isPositive ? item.positiveShare : item.mean).toFixed(3))),
278
+ barWidth: "54%",
279
+ itemStyle: { borderRadius: [0, 8, 8, 0] },
280
+ }],
281
+ };
282
+ }
283
+
284
+ function tooltip(formatter?: (value: unknown) => string) {
285
+ return {
286
+ trigger: "item",
287
+ backgroundColor: "rgba(255, 255, 255, .96)",
288
+ borderColor: "rgba(84, 96, 137, .18)",
289
+ textStyle: { color: "#18213a" },
290
+ valueFormatter: formatter,
291
+ };
292
+ }
293
+
294
+ function grid() {
295
+ return { left: 34, right: 18, top: 28, bottom: 32 };
296
+ }
297
+
298
+ function axisLabel() {
299
+ return { color: "rgba(24, 33, 58, .58)", fontSize: 11 };
300
+ }
301
+
302
+ function splitLine() {
303
+ return { lineStyle: { color: "rgba(84, 96, 137, .12)" } };
304
+ }
305
+
306
+ function labelOf<T extends string>(items: Array<{ id: T; label: string }>, id?: T) {
307
+ return items.find((item) => item.id === id)?.label || id || "-";
308
+ }
309
+
310
+ function modeLabel(mode: Breakdown) {
311
+ if (mode === "gender") return "성별";
312
+ if (mode === "age") return "연령대";
313
+ if (mode === "region") return "지역";
314
+ if (mode === "occupation") return "직업";
315
+ if (mode === "education") return "학력";
316
+ if (mode === "housing") return "주거";
317
+ if (mode === "marital") return "혼인";
318
+ if (mode === "family") return "가구";
319
+ return "전체";
320
+ }
321
+
322
+ function personaSummary(respondent?: SyntheticRespondent) {
323
+ if (!respondent) return "persona 없음";
324
+ return [
325
+ respondent.personaLabels.occupation,
326
+ respondent.personaLabels.education,
327
+ respondent.personaLabels.housing,
328
+ respondent.personaLabels.marital,
329
+ respondent.personaLabels.family,
330
+ ].filter(Boolean).join(" · ") || "persona 없음";
331
+ }
332
+
333
+ function formatNumber(value?: number) {
334
+ return typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : "-";
335
+ }
336
+
337
+ function formatPercent(value?: number) {
338
+ return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value * 100)}%` : "-";
339
+ }
frontend/src/silicon/SiliconSamplingApp.tsx ADDED
@@ -0,0 +1,1043 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { BarChart3, ChevronDown, ListChecks, Play, Plus, RotateCw, SlidersHorizontal, Wand2 } from "lucide-react";
3
+ import { AGE_BANDS, GENDERS, LOCATION_OPTIONS, PERSONA_OPTIONS, SURVEY_BANK } from "./data";
4
+ import { SiliconCharts } from "./SiliconCharts";
5
+ import { selectedWeightTotal } from "./simulate";
6
+ import type { AgeBandId, GenderId, LocationId, LocationOption, PersonaAttributeId, PersonaDimensionId, QuestionKind, RegionId, SiliconConfig, SiliconResult, SurveyQuestion, WeightedPick } from "./types";
7
+
8
+ declare global {
9
+ interface Window {
10
+ __siliconValidation?: {
11
+ getState: () => Record<string, unknown>;
12
+ run: () => void;
13
+ };
14
+ }
15
+ }
16
+
17
+ const SAMPLE_PRESETS = [10, 20, 100, 1000, 3000];
18
+ const MIN_SAMPLE_SIZE = 1;
19
+ const MAX_SAMPLE_SIZE = 3000;
20
+ const SIDO_LOCATION_OPTIONS = LOCATION_OPTIONS.filter((location) => location.level === "sido");
21
+ const FALLBACK_NEMOTRON_COLUMNS = [
22
+ "professional_persona",
23
+ "sports_persona",
24
+ "arts_persona",
25
+ "travel_persona",
26
+ "culinary_persona",
27
+ "family_persona",
28
+ "persona",
29
+ "cultural_background",
30
+ "skills_and_expertise",
31
+ "skills_and_expertise_list",
32
+ "hobbies_and_interests",
33
+ "hobbies_and_interests_list",
34
+ "career_goals_and_ambitions",
35
+ "sex",
36
+ "age",
37
+ "marital_status",
38
+ "military_status",
39
+ "family_type",
40
+ "housing_type",
41
+ "education_level",
42
+ "bachelors_field",
43
+ "occupation",
44
+ "district",
45
+ "province",
46
+ "country",
47
+ ];
48
+ const TEXT_NEMOTRON_COLUMNS = new Set([
49
+ "persona",
50
+ "professional_persona",
51
+ "sports_persona",
52
+ "arts_persona",
53
+ "travel_persona",
54
+ "culinary_persona",
55
+ "family_persona",
56
+ "cultural_background",
57
+ "skills_and_expertise",
58
+ "skills_and_expertise_list",
59
+ "hobbies_and_interests",
60
+ "hobbies_and_interests_list",
61
+ "career_goals_and_ambitions",
62
+ ]);
63
+ const NEMOTRON_COLUMN_LABELS: Record<string, string> = {
64
+ professional_persona: "직업 서사",
65
+ sports_persona: "스포츠 관심",
66
+ arts_persona: "예술 관심",
67
+ travel_persona: "여행 성향",
68
+ culinary_persona: "식생활 성향",
69
+ family_persona: "가족 서사",
70
+ persona: "종합 페르소나",
71
+ cultural_background: "문화 배경",
72
+ skills_and_expertise: "기술/전문성",
73
+ skills_and_expertise_list: "기술 목록",
74
+ hobbies_and_interests: "취미/관심사",
75
+ hobbies_and_interests_list: "취미 목록",
76
+ career_goals_and_ambitions: "진로 목표",
77
+ sex: "성별",
78
+ age: "나이",
79
+ marital_status: "혼인 상태",
80
+ military_status: "병역 상태",
81
+ family_type: "가구 형태",
82
+ housing_type: "주거 형태",
83
+ education_level: "학력",
84
+ bachelors_field: "전공",
85
+ occupation: "직업",
86
+ district: "시군구",
87
+ province: "시도",
88
+ country: "국가",
89
+ };
90
+ const PERSONA_DIMENSIONS: Array<{ id: PersonaDimensionId; label: string }> = [
91
+ { id: "occupation", label: "직업" },
92
+ { id: "education", label: "학력" },
93
+ { id: "housing", label: "주거" },
94
+ { id: "marital", label: "혼인" },
95
+ { id: "family", label: "가구" },
96
+ ];
97
+
98
+ type CountRow = { value: string; count: number };
99
+ type NemotronMetadata = {
100
+ columns?: string[];
101
+ sex_counts?: CountRow[];
102
+ age_buckets?: CountRow[];
103
+ province_counts?: CountRow[];
104
+ district_counts_by_province?: Record<string, CountRow[]>;
105
+ occupation_counts?: CountRow[];
106
+ education_counts?: CountRow[];
107
+ };
108
+
109
+ export function SiliconSamplingApp() {
110
+ const [sampleSize, setSampleSize] = useState(10);
111
+ const previousSampleSize = useRef(10);
112
+ const [genderPicks, setGenderPicks] = useState(() => emptyPicks(GENDERS));
113
+ const [agePicks, setAgePicks] = useState(() => emptyPicks(AGE_BANDS));
114
+ const [locationOptions, setLocationOptions] = useState<LocationOption[]>(() => SIDO_LOCATION_OPTIONS);
115
+ const [locationPicks, setLocationPicks] = useState(() => emptyPicks(SIDO_LOCATION_OPTIONS));
116
+ const [personaPicks, setPersonaPicks] = useState(() => emptyPicks(PERSONA_OPTIONS));
117
+ const [nemotronColumns, setNemotronColumns] = useState(() => FALLBACK_NEMOTRON_COLUMNS);
118
+ const [selectedNemotronFields, setSelectedNemotronFields] = useState(() => FALLBACK_NEMOTRON_COLUMNS);
119
+ const [datasetMetadata, setDatasetMetadata] = useState<NemotronMetadata | null>(null);
120
+ const [selectedQuestionIds, setSelectedQuestionIds] = useState<string[]>([]);
121
+ const [customQuestions, setCustomQuestions] = useState<SurveyQuestion[]>([]);
122
+ const [customTitle, setCustomTitle] = useState("");
123
+ const [customKind, setCustomKind] = useState<QuestionKind>("likert");
124
+ const [customScale, setCustomScale] = useState<4 | 5 | 7>(5);
125
+ const [questionBankOpen, setQuestionBankOpen] = useState(true);
126
+ const [seed, setSeed] = useState(20260629);
127
+ const [result, setResult] = useState<SiliconResult | null>(null);
128
+ const [isRunning, setIsRunning] = useState(false);
129
+ const [runStatus, setRunStatus] = useState("설정을 선택한 뒤 실행하세요.");
130
+ const [defaultRatioStatus, setDefaultRatioStatus] = useState("응답자 수를 정한 뒤 기본 비율을 적용할 수 있습니다.");
131
+ const questions = useMemo(
132
+ () => [...SURVEY_BANK.filter((question) => selectedQuestionIds.includes(question.id)), ...customQuestions],
133
+ [customQuestions, selectedQuestionIds],
134
+ );
135
+ const config = useMemo<SiliconConfig>(() => ({
136
+ sampleSize,
137
+ genders: genderPicks,
138
+ ages: agePicks,
139
+ locations: locationPicks,
140
+ locationOptions,
141
+ personaAttributes: personaPicks,
142
+ nemotronFields: selectedNemotronFields,
143
+ questions,
144
+ seed,
145
+ }), [agePicks, genderPicks, locationOptions, locationPicks, personaPicks, questions, sampleSize, seed, selectedNemotronFields]);
146
+ const likertCount = questions.filter((question) => question.kind === "likert").length;
147
+ const openCount = questions.filter((question) => question.kind === "open").length;
148
+ const displayLikertCount = result ? result.config.questions.filter((question) => question.kind === "likert").length : likertCount;
149
+ const displayOpenCount = result ? result.config.questions.filter((question) => question.kind === "open").length : openCount;
150
+ const displayLocationCount = result ? result.regionStats.filter((item) => item.respondents > 0).length : locationPicks.filter((item) => item.enabled).length;
151
+ const missingRequirements = missingRunRequirements(genderPicks, agePicks, locationPicks, questions);
152
+ const canRun = !isRunning && missingRequirements.length === 0;
153
+
154
+ useEffect(() => {
155
+ let cancelled = false;
156
+ fetch("/api/persona-dataset/metadata")
157
+ .then((response) => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
158
+ .then((metadata: NemotronMetadata) => {
159
+ if (cancelled) return;
160
+ const columns = usableNemotronColumns(metadata.columns);
161
+ const nextLocationOptions = buildLocationOptionsFromMetadata(metadata);
162
+ setDatasetMetadata(metadata);
163
+ setNemotronColumns(columns);
164
+ setSelectedNemotronFields((fields) => {
165
+ const current = fields.length ? fields : columns;
166
+ const selectedEverything = current.length === nemotronColumns.length && nemotronColumns.every((column) => current.includes(column));
167
+ return selectedEverything ? columns : current.filter((field) => columns.includes(field));
168
+ });
169
+ setLocationOptions(nextLocationOptions);
170
+ setLocationPicks((picks) => mergePicks(nextLocationOptions, picks));
171
+ })
172
+ .catch(() => {
173
+ if (!cancelled) setDatasetMetadata(null);
174
+ });
175
+ return () => {
176
+ cancelled = true;
177
+ };
178
+ }, []);
179
+
180
+ useEffect(() => {
181
+ const previous = previousSampleSize.current;
182
+ if (previous === sampleSize) return;
183
+ setGenderPicks((picks) => rescalePicksToTotal(picks, sampleSize));
184
+ setAgePicks((picks) => rescalePicksToTotal(picks, sampleSize));
185
+ setLocationPicks((picks) => rescalePicksToTotal(picks, sampleSize));
186
+ setPersonaPicks((picks) => rescalePersonaPicksToTotal(picks, sampleSize));
187
+ previousSampleSize.current = sampleSize;
188
+ }, [sampleSize]);
189
+
190
+ const run = async () => {
191
+ if (!canRun) {
192
+ setRunStatus(`실행 전 필요 항목: ${missingRequirements.join(", ")}`);
193
+ return;
194
+ }
195
+ setIsRunning(true);
196
+ setResult(null);
197
+ setRunStatus("페르소나별 LLM agent가 모든 문항에 답하는 중입니다.");
198
+ try {
199
+ const response = await fetch("/api/silicon/llm-run", {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ config: { ...config, seed: seed + 1 }, execution: { max_agents: sampleSize } }),
203
+ });
204
+ const payload = await response.json();
205
+ if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`);
206
+ setResult(payload as SiliconResult);
207
+ setSeed((value) => value + 1);
208
+ setRunStatus("시뮬레이션이 완료되었습니다.");
209
+ } catch (error) {
210
+ setRunStatus(`LLM 시뮬레이션 실패: ${error instanceof Error ? error.message : String(error)}`);
211
+ } finally {
212
+ setIsRunning(false);
213
+ }
214
+ };
215
+ const reset = () => {
216
+ setSampleSize(10);
217
+ previousSampleSize.current = 10;
218
+ setGenderPicks(emptyPicks(GENDERS));
219
+ setAgePicks(emptyPicks(AGE_BANDS));
220
+ setLocationPicks(emptyPicks(locationOptions));
221
+ setPersonaPicks(emptyPicks(PERSONA_OPTIONS));
222
+ setSelectedNemotronFields(nemotronColumns);
223
+ setSelectedQuestionIds([]);
224
+ setCustomQuestions([]);
225
+ setCustomTitle("");
226
+ setCustomKind("likert");
227
+ setCustomScale(5);
228
+ setSeed(20260629);
229
+ setResult(null);
230
+ setIsRunning(false);
231
+ setRunStatus("설정을 선택한 뒤 실행하세요.");
232
+ setDefaultRatioStatus("모든 선택값을 초기화했습니다.");
233
+ };
234
+ const applyDefaultRatios = async () => {
235
+ setDefaultRatioStatus("Nemotron-Personas-Korea metadata를 불러오는 중입니다.");
236
+ try {
237
+ const metadata = datasetMetadata || await fetchMetadata();
238
+ const columns = usableNemotronColumns(metadata.columns);
239
+ const nextLocationOptions = buildLocationOptionsFromMetadata(metadata);
240
+ const hadAllColumnsSelected = selectedNemotronFields.length === nemotronColumns.length && nemotronColumns.every((column) => selectedNemotronFields.includes(column));
241
+ const effectiveFields = hadAllColumnsSelected ? columns : selectedNemotronFields.filter((field) => columns.includes(field));
242
+ const fieldSet = new Set(effectiveFields);
243
+ setDatasetMetadata(metadata);
244
+ setNemotronColumns(columns);
245
+ setSelectedNemotronFields(effectiveFields);
246
+ setLocationOptions(nextLocationOptions);
247
+ setGenderPicks(fieldSet.has("sex") ? applyCountsToPicks(GENDERS, metadata.sex_counts, mapSexCount, sampleSize) : emptyPicks(GENDERS));
248
+ setAgePicks(fieldSet.has("age") ? applyCountsToPicks(AGE_BANDS, metadata.age_buckets, mapAgeCount, sampleSize) : emptyPicks(AGE_BANDS));
249
+ setLocationPicks(fieldSet.has("province") ? applyCountsToPicks(nextLocationOptions, metadata.province_counts, mapProvinceCount, sampleSize) : emptyPicks(nextLocationOptions));
250
+ setPersonaPicks(applyPersonaDefaults(metadata, fieldSet, sampleSize));
251
+ setDefaultRatioStatus(`선택된 Nemotron 컬럼 ${effectiveFields.length}개 기준으로 ${sampleSize.toLocaleString()}명을 할당했습니다.`);
252
+ setRunStatus("기본 비율이 적용되었습니다. 문항을 확인하고 실행하세요.");
253
+ } catch (error) {
254
+ const fieldSet = new Set(selectedNemotronFields);
255
+ setGenderPicks(fieldSet.has("sex") ? defaultCountPicks(GENDERS, sampleSize) : emptyPicks(GENDERS));
256
+ setAgePicks(fieldSet.has("age") ? defaultCountPicks(AGE_BANDS, sampleSize) : emptyPicks(AGE_BANDS));
257
+ setLocationPicks(fieldSet.has("province") ? defaultCountPicks(SIDO_LOCATION_OPTIONS, sampleSize) : emptyPicks(locationOptions));
258
+ setPersonaPicks(applyPersonaDefaults({}, fieldSet, sampleSize));
259
+ setDefaultRatioStatus(`metadata를 불러오지 못해 내장 기본 비율을 적용했습니다.`);
260
+ setRunStatus("내장 기본 비율이 적용되었습니다. 문항을 확인하고 실행하세요.");
261
+ }
262
+ };
263
+ const addCustomQuestion = () => {
264
+ const title = customTitle.trim();
265
+ if (!title) return;
266
+ const question: SurveyQuestion = {
267
+ id: `custom_${Date.now().toString(36)}`,
268
+ title,
269
+ source: "사용자 추가 문항",
270
+ category: customKind === "likert" ? "사용자 Likert" : "사용자 자유응답",
271
+ kind: customKind,
272
+ scale: customKind === "likert" ? customScale : undefined,
273
+ lowLabel: customKind === "likert" ? "낮음" : undefined,
274
+ highLabel: customKind === "likert" ? "높음" : undefined,
275
+ };
276
+ setCustomQuestions((items) => [...items, question]);
277
+ setCustomTitle("");
278
+ };
279
+
280
+ useEffect(() => {
281
+ window.__siliconValidation = {
282
+ getState: () => ({
283
+ sampleSize,
284
+ genderEnabled: genderPicks.filter((item) => item.enabled).length,
285
+ ageEnabled: agePicks.filter((item) => item.enabled).length,
286
+ locationEnabled: locationPicks.filter((item) => item.enabled).length,
287
+ personaEnabled: personaPicks.filter((item) => item.enabled).length,
288
+ nemotronColumns: nemotronColumns.length,
289
+ nemotronSelected: selectedNemotronFields.length,
290
+ locationOptionCount: locationOptions.length,
291
+ districtOptionCount: locationOptions.filter((location) => location.level === "district").length,
292
+ questionCount: questions.length,
293
+ likertCount,
294
+ openCount,
295
+ resultQuestionCount: result?.config.questions.length || 0,
296
+ resultLikertCount: result?.config.questions.filter((question) => question.kind === "likert").length || 0,
297
+ resultOpenCount: result?.config.questions.filter((question) => question.kind === "open").length || 0,
298
+ respondentCount: result?.respondents.length || 0,
299
+ genderCounts: result ? countBy(result.respondents.map((respondent) => respondent.gender)) : {},
300
+ genderAllocations: Object.fromEntries(genderPicks.filter((item) => item.enabled).map((item) => [item.id, item.weight])),
301
+ openAnswerCount: result?.openAnswers.length || 0,
302
+ regionStats: result?.regionStats.length || 0,
303
+ isRunning,
304
+ hasResult: Boolean(result),
305
+ setupMode: !isRunning && !result,
306
+ chartCards: document.querySelectorAll(".silicon-chart-card").length,
307
+ customQuestionVisible: customQuestions.length ? Boolean(document.querySelector("[data-testid='silicon-custom-question-list']")) : true,
308
+ samplePresetLabels: SAMPLE_PRESETS.map((value) => `${value}명`),
309
+ questionBankOpen,
310
+ }),
311
+ run,
312
+ };
313
+ }, [agePicks, customQuestions.length, genderPicks, isRunning, likertCount, locationOptions, locationPicks, nemotronColumns.length, openCount, personaPicks, questionBankOpen, questions.length, result, sampleSize, selectedNemotronFields.length]);
314
+
315
+ return (
316
+ <main className={!isRunning && !result ? "silicon-shell setup-only" : "silicon-shell has-results"}>
317
+ <section className="silicon-config">
318
+ <header className="silicon-hero">
319
+ <a href="/" className="silicon-brand">
320
+ <span />
321
+ <strong>Silicon Sampling Lab</strong>
322
+ </a>
323
+ <h1>한국 설문 Silicon Sampling</h1>
324
+ <p>성별, 연령, 지역, 문항을 토글과 숫자로 설정한 뒤 실행해 통계 결과를 확인합니다.</p>
325
+ <div className="silicon-actions">
326
+ <button data-testid="silicon-run-button" type="button" onClick={run} className="primary" disabled={!canRun}><Play size={16} /> 시뮬레이션 실행</button>
327
+ <button data-testid="silicon-reset-button" type="button" onClick={reset}><RotateCw size={16} /> 초기화</button>
328
+ </div>
329
+ <div className={isRunning ? "silicon-run-chip running" : "silicon-run-chip"} data-testid="silicon-run-status">{runStatus}</div>
330
+ </header>
331
+
332
+ <div className="silicon-setup-grid">
333
+ <section className="silicon-card sample-card">
334
+ <div className="silicon-section-head">
335
+ <SlidersHorizontal size={16} />
336
+ <div>
337
+ <span>sample size</span>
338
+ <strong>응답자 수</strong>
339
+ </div>
340
+ </div>
341
+ <div className="sample-presets">
342
+ {SAMPLE_PRESETS.map((value) => (
343
+ <button key={value} type="button" className={sampleSize === value ? "active" : ""} onClick={() => setSampleSize(value)}>{value.toLocaleString()}명</button>
344
+ ))}
345
+ </div>
346
+ <label className="number-field">
347
+ <span>직접 입력</span>
348
+ <input data-testid="silicon-sample-input" type="number" min={MIN_SAMPLE_SIZE} max={MAX_SAMPLE_SIZE} step={1} value={sampleSize} onChange={(event) => setSampleSize(clampNumber(event.target.value, MIN_SAMPLE_SIZE, MAX_SAMPLE_SIZE))} />
349
+ </label>
350
+ <div className="default-ratio-box">
351
+ <button data-testid="silicon-default-ratio-button" type="button" onClick={applyDefaultRatios}><Wand2 size={15} /> 기본 비율 적용</button>
352
+ <span>{defaultRatioStatus}</span>
353
+ </div>
354
+ </section>
355
+
356
+ <ToggleGroup title="성별" subtitle="복수 선택, 실제 명수 합계 기준" items={GENDERS} picks={genderPicks} onChange={setGenderPicks} total={selectedWeightTotal(genderPicks)} sampleSize={sampleSize} />
357
+ <ToggleGroup title="연령" subtitle="20대, 30대 단위, 실제 명수 합계 기준" items={AGE_BANDS} picks={agePicks} onChange={setAgePicks} total={selectedWeightTotal(agePicks)} sampleSize={sampleSize} />
358
+ <LocationSelector picks={locationPicks} onChange={setLocationPicks} sampleSize={sampleSize} locationOptions={locationOptions} />
359
+ <PersonaSelector picks={personaPicks} onChange={setPersonaPicks} sampleSize={sampleSize} />
360
+ <NemotronColumnSelector columns={nemotronColumns} selected={selectedNemotronFields} onChange={setSelectedNemotronFields} />
361
+
362
+ <section className="silicon-card question-card">
363
+ <div className="silicon-section-head">
364
+ <ListChecks size={16} />
365
+ <div>
366
+ <span>survey items</span>
367
+ <strong>대표 설문 문항</strong>
368
+ </div>
369
+ <button
370
+ type="button"
371
+ className={questionBankOpen ? "question-bank-toggle open" : "question-bank-toggle"}
372
+ data-testid="survey-bank-toggle"
373
+ aria-expanded={questionBankOpen}
374
+ onClick={() => setQuestionBankOpen((value) => !value)}
375
+ >
376
+ <ChevronDown size={15} />
377
+ {questionBankOpen ? "접기" : "열기"}
378
+ </button>
379
+ </div>
380
+ {questionBankOpen ? (
381
+ <div className="question-bank" data-testid="survey-bank-list">
382
+ {SURVEY_BANK.map((question) => (
383
+ <button
384
+ key={question.id}
385
+ type="button"
386
+ className={[
387
+ selectedQuestionIds.includes(question.id) ? "selected" : "",
388
+ question.kind === "likert" ? "kind-likert" : "kind-open",
389
+ ].filter(Boolean).join(" ")}
390
+ onClick={() => setSelectedQuestionIds((ids) => ids.includes(question.id) ? ids.filter((id) => id !== question.id) : [...ids, question.id])}
391
+ >
392
+ <span>{question.category} · {question.kind === "likert" ? `${question.scale}점` : "자유응답"}</span>
393
+ <strong>{question.title}</strong>
394
+ <em>{question.source}</em>
395
+ </button>
396
+ ))}
397
+ </div>
398
+ ) : null}
399
+ <div className="custom-question">
400
+ <div className="custom-kind">
401
+ <button type="button" className={customKind === "likert" ? "active" : ""} onClick={() => setCustomKind("likert")}>Likert</button>
402
+ <button type="button" className={customKind === "open" ? "active" : ""} onClick={() => setCustomKind("open")}>Open-ended</button>
403
+ {customKind === "likert" ? (
404
+ <select value={customScale} onChange={(event) => setCustomScale(Number(event.target.value) as 4 | 5 | 7)}>
405
+ <option value={4}>4점</option>
406
+ <option value={5}>5점</option>
407
+ <option value={7}>7점</option>
408
+ </select>
409
+ ) : null}
410
+ </div>
411
+ <label>
412
+ <span>문항 추가</span>
413
+ <input data-testid="silicon-custom-question-input" value={customTitle} onChange={(event) => setCustomTitle(event.target.value)} placeholder="예: 지역 의료 접근성에 만족하십니까?" />
414
+ </label>
415
+ <button data-testid="silicon-custom-question-add" type="button" onClick={addCustomQuestion}><Plus size={15} /> 추가</button>
416
+ </div>
417
+ {customQuestions.length ? (
418
+ <div className="custom-question-list" data-testid="silicon-custom-question-list">
419
+ <span>추가한 문항</span>
420
+ {customQuestions.map((question) => (
421
+ <article key={question.id} className={question.kind === "likert" ? "kind-likert" : "kind-open"}>
422
+ <em>{question.kind === "likert" ? `${question.scale}점 Likert` : "Open-ended"}</em>
423
+ <strong>{question.title}</strong>
424
+ </article>
425
+ ))}
426
+ </div>
427
+ ) : null}
428
+ </section>
429
+ </div>
430
+ </section>
431
+
432
+ {(isRunning || result) ? <section className="silicon-results">
433
+ <header className="silicon-result-head">
434
+ <div>
435
+ <span>result dashboard</span>
436
+ <h2>{result ? `${result.respondents.length.toLocaleString()}명 결과` : "실행 대기"}</h2>
437
+ </div>
438
+ <div className="result-stats">
439
+ <span><strong>{displayLikertCount}</strong> Likert</span>
440
+ <span><strong>{displayOpenCount}</strong> open-ended</span>
441
+ <span><strong>{displayLocationCount}</strong> locations</span>
442
+ </div>
443
+ </header>
444
+ {isRunning ? <SimulationRunning status={runStatus} /> : null}
445
+ {!isRunning && !result ? <SimulationEmptyState /> : null}
446
+ {result ? (
447
+ <>
448
+ <section className="silicon-summary-panel">
449
+ <header>
450
+ <BarChart3 size={16} />
451
+ <div>
452
+ <span>statistical readout</span>
453
+ <strong>주요 결과</strong>
454
+ </div>
455
+ </header>
456
+ <Metric label="평균 점수" value={formatNumber(result.questionStats.find((item) => item.questionId === result.primaryQuestionId)?.mean)} />
457
+ <Metric label="긍정 응답률" value={formatPercent(result.questionStats.find((item) => item.questionId === result.primaryQuestionId)?.positiveShare)} />
458
+ <Metric label="자유응답 수" value={`${result.openAnswers.length.toLocaleString()}개`} />
459
+ <Metric label="최다 응답 지역" value={topRegion(result)} />
460
+ <div className="method-note">
461
+ <BarChart3 size={15} />
462
+ <span>가중 토글 분포에서 persona-agent를 만들고, 각 agent가 모든 문항에 LLM으로 응답한 뒤 문항별 통계를 집계합니다.</span>
463
+ </div>
464
+ </section>
465
+ <SiliconCharts result={result} />
466
+ </>
467
+ ) : null}
468
+ </section> : null}
469
+ </main>
470
+ );
471
+ }
472
+
473
+ function ToggleGroup<T extends GenderId | AgeBandId>({
474
+ title,
475
+ subtitle,
476
+ items,
477
+ picks,
478
+ onChange,
479
+ total,
480
+ sampleSize,
481
+ compact = false,
482
+ }: {
483
+ title: string;
484
+ subtitle: string;
485
+ items: Array<{ id: T; label: string; defaultWeight: number }>;
486
+ picks: WeightedPick<T>[];
487
+ onChange: (next: WeightedPick<T>[]) => void;
488
+ total: number;
489
+ sampleSize: number;
490
+ compact?: boolean;
491
+ }) {
492
+ const update = (id: T, patch: Partial<WeightedPick<T>>) => onChange(picks.map((pick) => pick.id === id ? { ...pick, ...patch } : pick));
493
+ return (
494
+ <section className="silicon-card">
495
+ <div className="toggle-head">
496
+ <div>
497
+ <span>{subtitle}</span>
498
+ <strong>{title}</strong>
499
+ </div>
500
+ <em>합계 {total.toLocaleString()}명</em>
501
+ </div>
502
+ <div className={compact ? "toggle-grid compact" : "toggle-grid"}>
503
+ {items.map((item) => {
504
+ const pick = picks.find((entry) => entry.id === item.id) || { id: item.id, enabled: false, weight: item.defaultWeight };
505
+ return (
506
+ <div key={item.id} className={pick.enabled ? "toggle-chip active" : "toggle-chip"}>
507
+ <button type="button" onClick={() => update(item.id, { enabled: !pick.enabled })}>{item.label}</button>
508
+ <input
509
+ aria-label={`${item.label} 명수`}
510
+ type="number"
511
+ min={0}
512
+ max={sampleSize}
513
+ value={pick.enabled ? pick.weight : ""}
514
+ disabled={!pick.enabled}
515
+ onChange={(event) => update(item.id, { weight: clampNumber(event.target.value, 0, sampleSize) })}
516
+ />
517
+ <span className="estimate-count">{pick.enabled ? formatShare(pick.weight, sampleSize) : ""}</span>
518
+ </div>
519
+ );
520
+ })}
521
+ </div>
522
+ </section>
523
+ );
524
+ }
525
+
526
+ function LocationSelector({
527
+ picks,
528
+ onChange,
529
+ sampleSize,
530
+ locationOptions,
531
+ }: {
532
+ picks: WeightedPick<LocationId>[];
533
+ onChange: (next: WeightedPick<LocationId>[]) => void;
534
+ sampleSize: number;
535
+ locationOptions: LocationOption[];
536
+ }) {
537
+ const [sidoOpen, setSidoOpen] = useState(false);
538
+ const [activeParent, setActiveParent] = useState<RegionId | null>(null);
539
+ const selectedParents = locationOptions
540
+ .filter((location) => location.level === "sido" && picks.find((pick) => pick.id === location.id)?.enabled)
541
+ .map((location) => location.id as RegionId);
542
+ const enabledCount = picks.filter((pick) => pick.enabled).length;
543
+ useEffect(() => {
544
+ if (enabledCount > 0) setSidoOpen(true);
545
+ }, [enabledCount]);
546
+ useEffect(() => {
547
+ if (activeParent && selectedParents.includes(activeParent)) return;
548
+ setActiveParent(selectedParents[0] || null);
549
+ }, [activeParent, selectedParents]);
550
+ const sidoOptions = locationOptions.filter((location) => location.level === "sido");
551
+ const activeDistricts = activeParent
552
+ ? locationOptions.filter((location) => location.level === "district" && location.parentRegion === activeParent)
553
+ : [];
554
+ const total = selectedWeightTotal(picks);
555
+ const update = (id: LocationId, patch: Partial<WeightedPick<LocationId>>) => {
556
+ const option = locationOptions.find((location) => location.id === id);
557
+ const next = picks.map((pick) => {
558
+ if (pick.id === id) return { ...pick, ...patch };
559
+ if (option?.level === "sido" && patch.enabled === false) {
560
+ const child = locationOptions.find((location) => location.id === pick.id);
561
+ if (child?.level === "district" && child.parentRegion === option.parentRegion) return { ...pick, enabled: false };
562
+ }
563
+ return pick;
564
+ });
565
+ if (option?.level === "sido" && patch.enabled !== false) setActiveParent(option.parentRegion);
566
+ onChange(next);
567
+ };
568
+ return (
569
+ <section className="silicon-card location-card">
570
+ <div className="toggle-head">
571
+ <div>
572
+ <span>시도를 먼저 선택하면 해당 시도의 실제 Nemotron 세부지역만 표시</span>
573
+ <strong>지역</strong>
574
+ </div>
575
+ <em>합계 {total.toLocaleString()}명</em>
576
+ </div>
577
+ <div className="location-tabs">
578
+ <button type="button" className={sidoOpen ? "active" : ""} data-testid="location-tab-sido" onClick={() => setSidoOpen((value) => !value)}>시도 선택</button>
579
+ </div>
580
+ {sidoOpen ? (
581
+ <div className="location-toggle-grid" data-testid="silicon-location-allocation">
582
+ {sidoOptions.map((location) => (
583
+ <WeightedOptionChip
584
+ key={location.id}
585
+ item={location}
586
+ pick={picks.find((pick) => pick.id === location.id) || { id: location.id, enabled: false, weight: location.defaultWeight }}
587
+ onChange={(patch) => update(location.id, patch)}
588
+ sampleSize={sampleSize}
589
+ />
590
+ ))}
591
+ </div>
592
+ ) : (
593
+ <p className="selector-empty-note" data-testid="silicon-location-empty">시도 선택을 누르면 목록이 열립니다.</p>
594
+ )}
595
+ {selectedParents.length ? (
596
+ <div className="location-detail-panel">
597
+ <div className="location-detail-tabs" data-testid="silicon-location-parent-tabs">
598
+ {selectedParents.map((parent) => (
599
+ <button key={parent} type="button" className={activeParent === parent ? "active" : ""} data-testid={`location-parent-${parent}`} onClick={() => setActiveParent(parent)}>
600
+ {locationLabel(parent)}
601
+ </button>
602
+ ))}
603
+ </div>
604
+ {activeDistricts.length ? (
605
+ <div className="location-toggle-grid detail-grid" data-testid="silicon-location-detail-allocation">
606
+ {activeDistricts.map((location) => (
607
+ <WeightedOptionChip
608
+ key={location.id}
609
+ item={location}
610
+ pick={picks.find((pick) => pick.id === location.id) || { id: location.id, enabled: false, weight: location.defaultWeight }}
611
+ onChange={(patch) => update(location.id, patch)}
612
+ sampleSize={sampleSize}
613
+ />
614
+ ))}
615
+ </div>
616
+ ) : (
617
+ <p className="selector-empty-note">선택한 시도에 연결된 세부지역이 없습니다.</p>
618
+ )}
619
+ </div>
620
+ ) : null}
621
+ </section>
622
+ );
623
+ }
624
+
625
+ function PersonaSelector({ picks, onChange, sampleSize }: { picks: WeightedPick<PersonaAttributeId>[]; onChange: (next: WeightedPick<PersonaAttributeId>[]) => void; sampleSize: number }) {
626
+ const [dimension, setDimension] = useState<PersonaDimensionId>("occupation");
627
+ const available = PERSONA_OPTIONS.filter((option) => option.dimension === dimension);
628
+ const total = selectedWeightTotal(picks.filter((pick) => available.some((option) => option.id === pick.id)));
629
+ const update = (id: PersonaAttributeId, patch: Partial<WeightedPick<PersonaAttributeId>>) => onChange(picks.map((pick) => pick.id === id ? { ...pick, ...patch } : pick));
630
+ return (
631
+ <section className="silicon-card persona-card">
632
+ <div className="toggle-head">
633
+ <div>
634
+ <span>직업, 학력, 주거 등 persona 항목별 실제 명수 선택</span>
635
+ <strong>페르소나 속성</strong>
636
+ </div>
637
+ <em>{dimensionLabel(dimension)} 합계 {total.toLocaleString()}명</em>
638
+ </div>
639
+ <div className="location-tabs">
640
+ {PERSONA_DIMENSIONS.map((item) => (
641
+ <button key={item.id} type="button" className={dimension === item.id ? "active" : ""} data-testid={`persona-tab-${item.id}`} onClick={() => setDimension(item.id)}>{item.label}</button>
642
+ ))}
643
+ </div>
644
+ <div className="location-toggle-grid persona-toggle-grid" data-testid="silicon-persona-allocation">
645
+ {available.map((option) => (
646
+ <WeightedOptionChip
647
+ key={option.id}
648
+ item={option}
649
+ pick={picks.find((pick) => pick.id === option.id) || { id: option.id, enabled: false, weight: option.defaultWeight }}
650
+ onChange={(patch) => update(option.id, patch)}
651
+ sampleSize={sampleSize}
652
+ />
653
+ ))}
654
+ </div>
655
+ </section>
656
+ );
657
+ }
658
+
659
+ function NemotronColumnSelector({
660
+ columns,
661
+ selected,
662
+ onChange,
663
+ }: {
664
+ columns: string[];
665
+ selected: string[];
666
+ onChange: (next: string[]) => void;
667
+ }) {
668
+ const selectedSet = new Set(selected);
669
+ const demographicColumns = columns.filter((column) => !TEXT_NEMOTRON_COLUMNS.has(column));
670
+ const textColumns = columns.filter((column) => TEXT_NEMOTRON_COLUMNS.has(column));
671
+ const toggle = (column: string) => {
672
+ onChange(selectedSet.has(column) ? selected.filter((item) => item !== column) : [...selected, column]);
673
+ };
674
+ return (
675
+ <section className="silicon-card nemotron-card">
676
+ <div className="toggle-head">
677
+ <div>
678
+ <span>기본 전체 적용, 필요한 컬럼만 해제 가능</span>
679
+ <strong>Nemotron 컬럼</strong>
680
+ </div>
681
+ <em>{selected.length}/{columns.length}개</em>
682
+ </div>
683
+ <div className="nemotron-actions">
684
+ <button data-testid="nemotron-select-all" type="button" onClick={() => onChange(columns)}>전체 선택</button>
685
+ <button data-testid="nemotron-select-text" type="button" onClick={() => onChange(textColumns)}>텍스트만</button>
686
+ <button data-testid="nemotron-clear" type="button" onClick={() => onChange([])}>모두 해제</button>
687
+ </div>
688
+ <div className="nemotron-column-section">
689
+ <span>분포/필터 컬럼</span>
690
+ <div className="nemotron-column-grid" data-testid="nemotron-demographic-columns">
691
+ {demographicColumns.map((column) => (
692
+ <button key={column} type="button" className={selectedSet.has(column) ? "active" : ""} data-testid={`nemotron-column-${column}`} onClick={() => toggle(column)}>
693
+ {nemotronColumnLabel(column)}
694
+ </button>
695
+ ))}
696
+ </div>
697
+ </div>
698
+ <div className="nemotron-column-section">
699
+ <span>자연어 persona 컬럼</span>
700
+ <div className="nemotron-column-grid" data-testid="nemotron-text-columns">
701
+ {textColumns.map((column) => (
702
+ <button key={column} type="button" className={selectedSet.has(column) ? "active" : ""} data-testid={`nemotron-column-${column}`} onClick={() => toggle(column)}>
703
+ {nemotronColumnLabel(column)}
704
+ </button>
705
+ ))}
706
+ </div>
707
+ </div>
708
+ </section>
709
+ );
710
+ }
711
+
712
+ function WeightedOptionChip<T extends string>({
713
+ item,
714
+ pick,
715
+ onChange,
716
+ sampleSize,
717
+ }: {
718
+ item: { id: T; label: string; defaultWeight: number };
719
+ pick: WeightedPick<T>;
720
+ onChange: (patch: Partial<WeightedPick<T>>) => void;
721
+ sampleSize: number;
722
+ }) {
723
+ return (
724
+ <div className={pick.enabled ? "toggle-chip active" : "toggle-chip"} data-testid={`weighted-option-${item.id}`}>
725
+ <button type="button" data-testid={`weighted-option-button-${item.id}`} onClick={() => onChange({ enabled: !pick.enabled, weight: pick.weight || defaultCountFromShare(item.defaultWeight, sampleSize) })}>{item.label}</button>
726
+ <input
727
+ aria-label={`${item.label} 명수`}
728
+ type="number"
729
+ min={0}
730
+ max={sampleSize}
731
+ value={pick.enabled ? pick.weight : ""}
732
+ disabled={!pick.enabled}
733
+ onChange={(event) => onChange({ weight: clampNumber(event.target.value, 0, sampleSize) })}
734
+ />
735
+ <span className="estimate-count">{pick.enabled ? formatShare(pick.weight, sampleSize) : ""}</span>
736
+ </div>
737
+ );
738
+ }
739
+
740
+ function SimulationEmptyState() {
741
+ return (
742
+ <section className="silicon-state-panel" data-testid="silicon-empty-state">
743
+ <BarChart3 size={22} />
744
+ <div>
745
+ <span>ready</span>
746
+ <strong>설정을 마친 뒤 시뮬레이션을 실행하세요.</strong>
747
+ <p>결과는 실행이 끝난 뒤 문항과 분석축을 선택해서 보는 탐색형 대시보드로 표시됩니다.</p>
748
+ </div>
749
+ </section>
750
+ );
751
+ }
752
+
753
+ function SimulationRunning({ status }: { status: string }) {
754
+ return (
755
+ <section className="silicon-state-panel running" data-testid="silicon-running-state">
756
+ <div className="silicon-spinner" />
757
+ <div>
758
+ <span>running</span>
759
+ <strong>{status}</strong>
760
+ <p>각 persona-agent가 선택된 모든 문항에 답하고, 결과를 문항별로 추출하고 있습니다.</p>
761
+ </div>
762
+ </section>
763
+ );
764
+ }
765
+
766
+ function Metric({ label, value }: { label: string; value: string }) {
767
+ return (
768
+ <div className="silicon-metric">
769
+ <span>{label}</span>
770
+ <strong>{value}</strong>
771
+ </div>
772
+ );
773
+ }
774
+
775
+ function topRegion(result: SiliconResult) {
776
+ const top = [...result.regionStats].sort((a, b) => b.respondents - a.respondents)[0];
777
+ return top ? `${top.label} ${top.respondents.toLocaleString()}명` : "-";
778
+ }
779
+
780
+ function countBy(values: string[]) {
781
+ return values.reduce<Record<string, number>>((counts, value) => {
782
+ counts[value] = (counts[value] || 0) + 1;
783
+ return counts;
784
+ }, {});
785
+ }
786
+
787
+ function formatNumber(value?: number) {
788
+ return typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : "-";
789
+ }
790
+
791
+ function formatPercent(value?: number) {
792
+ return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value * 100)}%` : "-";
793
+ }
794
+
795
+ function formatShare(count: number, sampleSize: number) {
796
+ if (!sampleSize) return "0%";
797
+ const share = count / sampleSize * 100;
798
+ return `${share.toFixed(1).replace(/\.0$/, "")}%`;
799
+ }
800
+
801
+ function defaultCountFromShare(share: number, sampleSize: number) {
802
+ return Math.max(1, Math.round(sampleSize * Math.max(0, share) / 100));
803
+ }
804
+
805
+ function clampNumber(value: string, min: number, max: number) {
806
+ const number = Number(value);
807
+ if (!Number.isFinite(number)) return min;
808
+ return Math.max(min, Math.min(max, Math.round(number)));
809
+ }
810
+
811
+ function emptyPicks<T extends string>(items: Array<{ id: T; defaultWeight: number }>): WeightedPick<T>[] {
812
+ return items.map((item) => ({ id: item.id, enabled: false, weight: 0 }));
813
+ }
814
+
815
+ function defaultCountPicks<T extends string>(items: Array<{ id: T; defaultWeight: number }>, targetTotal: number): WeightedPick<T>[] {
816
+ const counts = allocateCounts(new Map(items.map((item) => [item.id, Math.max(0, item.defaultWeight)])), targetTotal);
817
+ return items.map((item) => ({ id: item.id, enabled: Boolean(counts.get(item.id)), weight: counts.get(item.id) || 0 }));
818
+ }
819
+
820
+ function missingRunRequirements(
821
+ genders: WeightedPick<GenderId>[],
822
+ ages: WeightedPick<AgeBandId>[],
823
+ locations: WeightedPick<LocationId>[],
824
+ questions: SurveyQuestion[],
825
+ ) {
826
+ const missing: string[] = [];
827
+ if (selectedWeightTotal(genders) <= 0) missing.push("성별");
828
+ if (selectedWeightTotal(ages) <= 0) missing.push("연령");
829
+ if (selectedWeightTotal(locations) <= 0) missing.push("지역");
830
+ if (!questions.length) missing.push("문항");
831
+ return missing;
832
+ }
833
+
834
+ function applyCountsToPicks<T extends string>(
835
+ items: Array<{ id: T; defaultWeight: number }>,
836
+ counts: CountRow[] | undefined,
837
+ mapper: (value: string) => T | null,
838
+ targetTotal: number,
839
+ ): WeightedPick<T>[] {
840
+ const validIds = new Set(items.map((item) => item.id));
841
+ const bucketCounts = new Map<T, number>();
842
+ for (const row of counts || []) {
843
+ const id = mapper(String(row.value || ""));
844
+ if (!id || !validIds.has(id)) continue;
845
+ bucketCounts.set(id, (bucketCounts.get(id) || 0) + Number(row.count || 0));
846
+ }
847
+ const weights = allocateCounts(bucketCounts, targetTotal);
848
+ return items.map((item) => ({
849
+ id: item.id,
850
+ enabled: Boolean(weights.get(item.id)),
851
+ weight: weights.get(item.id) || 0,
852
+ }));
853
+ }
854
+
855
+ function usableNemotronColumns(columns: unknown) {
856
+ if (!Array.isArray(columns)) return FALLBACK_NEMOTRON_COLUMNS;
857
+ const usable = columns.map((column) => String(column)).filter((column) => column !== "uuid");
858
+ return usable.length ? usable : FALLBACK_NEMOTRON_COLUMNS;
859
+ }
860
+
861
+ async function fetchMetadata(): Promise<NemotronMetadata> {
862
+ const response = await fetch("/api/persona-dataset/metadata");
863
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
864
+ return response.json();
865
+ }
866
+
867
+ function buildLocationOptionsFromMetadata(metadata: NemotronMetadata): LocationOption[] {
868
+ const rowCount = (metadata.province_counts || []).reduce((sum, row) => sum + Math.max(0, Number(row.count || 0)), 0);
869
+ const districtOptions: LocationOption[] = [];
870
+ const seen = new Set(SIDO_LOCATION_OPTIONS.map((location) => location.id));
871
+ for (const [province, rows] of Object.entries(metadata.district_counts_by_province || {})) {
872
+ const parentRegion = mapProvinceCount(province) as RegionId | null;
873
+ if (!parentRegion) continue;
874
+ for (const row of rows || []) {
875
+ const raw = String(row.value || "").trim();
876
+ if (!raw) continue;
877
+ const id = `district_${parentRegion}_${slugLocationId(raw)}`;
878
+ if (seen.has(id)) continue;
879
+ seen.add(id);
880
+ const count = Math.max(0, Number(row.count || 0));
881
+ districtOptions.push({
882
+ id,
883
+ label: stripDistrictPrefix(raw),
884
+ short: stripDistrictPrefix(raw),
885
+ defaultWeight: rowCount ? count / rowCount * 100 : 1,
886
+ parentRegion,
887
+ level: "district",
888
+ group: `${locationLabel(parentRegion)} 세부`,
889
+ });
890
+ }
891
+ }
892
+ return [...SIDO_LOCATION_OPTIONS, ...districtOptions];
893
+ }
894
+
895
+ function mergePicks<T extends string>(items: Array<{ id: T }>, current: WeightedPick<T>[]) {
896
+ const currentById = new Map(current.map((pick) => [pick.id, pick]));
897
+ return items.map((item) => currentById.get(item.id) || { id: item.id, enabled: false, weight: 0 });
898
+ }
899
+
900
+ function rescalePicksToTotal<T extends string>(picks: WeightedPick<T>[], targetTotal: number) {
901
+ const active = picks.filter((pick) => pick.enabled && pick.weight > 0);
902
+ if (!active.length) return picks;
903
+ const counts = allocateCounts(new Map(active.map((pick) => [pick.id, pick.weight])), targetTotal);
904
+ return picks.map((pick) => pick.enabled ? { ...pick, weight: counts.get(pick.id) || 0 } : pick);
905
+ }
906
+
907
+ function rescalePersonaPicksToTotal(picks: WeightedPick<PersonaAttributeId>[], targetTotal: number) {
908
+ let next = picks;
909
+ for (const dimension of PERSONA_DIMENSIONS) {
910
+ const ids = new Set(PERSONA_OPTIONS.filter((option) => option.dimension === dimension.id).map((option) => option.id));
911
+ const dimensionPicks = next.filter((pick) => ids.has(pick.id));
912
+ const rescaled = new Map(rescalePicksToTotal(dimensionPicks, targetTotal).map((pick) => [pick.id, pick]));
913
+ next = next.map((pick) => rescaled.get(pick.id) || pick);
914
+ }
915
+ return next;
916
+ }
917
+
918
+ function slugLocationId(value: string) {
919
+ return value.trim().replace(/[^0-9A-Za-z가-힣]+/g, "_").replace(/^_+|_+$/g, "");
920
+ }
921
+
922
+ function stripDistrictPrefix(value: string) {
923
+ return value.replace(/^[^-]+-/, "");
924
+ }
925
+
926
+ function allocateCounts<T extends string>(counts: Map<T, number>, targetTotal: number) {
927
+ const total = [...counts.values()].reduce((sum, count) => sum + Math.max(0, count), 0);
928
+ if (total <= 0) return new Map<T, number>();
929
+ const rows = [...counts.entries()].map(([id, count]) => {
930
+ const raw = Math.max(0, count) / total * targetTotal;
931
+ return { id, floor: Math.floor(raw), remainder: raw - Math.floor(raw) };
932
+ });
933
+ let used = rows.reduce((sum, row) => sum + row.floor, 0);
934
+ for (const row of rows.sort((a, b) => b.remainder - a.remainder)) {
935
+ if (used >= targetTotal) break;
936
+ row.floor += 1;
937
+ used += 1;
938
+ }
939
+ return new Map(rows.filter((row) => row.floor > 0).map((row) => [row.id, row.floor]));
940
+ }
941
+
942
+ function mapSexCount(value: string): GenderId | null {
943
+ if (value.includes("남")) return "male";
944
+ if (value.includes("여")) return "female";
945
+ return null;
946
+ }
947
+
948
+ function mapAgeCount(value: string): AgeBandId | null {
949
+ if (value === "20s") return "20s";
950
+ if (value === "30s") return "30s";
951
+ if (value === "40s") return "40s";
952
+ if (value === "50s") return "50s";
953
+ if (value === "60_plus" || value === "60plus") return "60plus";
954
+ return null;
955
+ }
956
+
957
+ function mapProvinceCount(value: string): LocationId | null {
958
+ const normalized = value.replace(/\s/g, "");
959
+ const provinceMap: Record<string, LocationId> = {
960
+ 서울: "seoul",
961
+ 부산: "busan",
962
+ 대구: "daegu",
963
+ 인천: "incheon",
964
+ 광주: "gwangju",
965
+ 대전: "daejeon",
966
+ 울산: "ulsan",
967
+ 세종: "sejong",
968
+ 경기: "gyeonggi",
969
+ 강원: "gangwon",
970
+ 충북: "chungbuk",
971
+ 충청북: "chungbuk",
972
+ 충청북도: "chungbuk",
973
+ 충남: "chungnam",
974
+ 충청남: "chungnam",
975
+ 충청남도: "chungnam",
976
+ 전북: "jeonbuk",
977
+ 전라북: "jeonbuk",
978
+ 전라북도: "jeonbuk",
979
+ 전남: "jeonnam",
980
+ 전라남: "jeonnam",
981
+ 전라남도: "jeonnam",
982
+ 경북: "gyeongbuk",
983
+ 경상북: "gyeongbuk",
984
+ 경상북도: "gyeongbuk",
985
+ 경남: "gyeongnam",
986
+ 경상남: "gyeongnam",
987
+ 경상남도: "gyeongnam",
988
+ 제주: "jeju",
989
+ };
990
+ return provinceMap[normalized] || null;
991
+ }
992
+
993
+ function applyPersonaDefaults(metadata: { occupation_counts?: CountRow[]; education_counts?: CountRow[] }, selectedFields: Set<string>, targetTotal: number) {
994
+ const occupationOptions = PERSONA_OPTIONS.filter((option) => option.dimension === "occupation");
995
+ const educationOptions = PERSONA_OPTIONS.filter((option) => option.dimension === "education");
996
+ const occupation = selectedFields.has("occupation")
997
+ ? metadata.occupation_counts ? applyCountsToPicks(occupationOptions, metadata.occupation_counts, mapOccupationCount, targetTotal) : defaultCountPicks(occupationOptions, targetTotal)
998
+ : emptyPicks(occupationOptions);
999
+ const education = selectedFields.has("education_level")
1000
+ ? metadata.education_counts ? applyCountsToPicks(educationOptions, metadata.education_counts, mapEducationCount, targetTotal) : defaultCountPicks(educationOptions, targetTotal)
1001
+ : emptyPicks(educationOptions);
1002
+ const staticDimensions = [
1003
+ ...(selectedFields.has("housing_type") ? defaultCountPicks(PERSONA_OPTIONS.filter((option) => option.dimension === "housing"), targetTotal) : emptyPicks(PERSONA_OPTIONS.filter((option) => option.dimension === "housing"))),
1004
+ ...(selectedFields.has("marital_status") ? defaultCountPicks(PERSONA_OPTIONS.filter((option) => option.dimension === "marital"), targetTotal) : emptyPicks(PERSONA_OPTIONS.filter((option) => option.dimension === "marital"))),
1005
+ ...(selectedFields.has("family_type") ? defaultCountPicks(PERSONA_OPTIONS.filter((option) => option.dimension === "family"), targetTotal) : emptyPicks(PERSONA_OPTIONS.filter((option) => option.dimension === "family"))),
1006
+ ];
1007
+ const merged = new Map<PersonaAttributeId, WeightedPick<PersonaAttributeId>>();
1008
+ [...occupation, ...education, ...staticDimensions].forEach((pick) => merged.set(pick.id, pick));
1009
+ return PERSONA_OPTIONS.map((option) => merged.get(option.id) || { id: option.id, enabled: false, weight: option.defaultWeight });
1010
+ }
1011
+
1012
+ function mapOccupationCount(value: string): PersonaAttributeId | null {
1013
+ const text = value.toLowerCase();
1014
+ if (text.includes("학생")) return "occ_student";
1015
+ if (text.includes("주부")) return "occ_homemaker";
1016
+ if (text.includes("자영") || text.includes("사업")) return "occ_self_employed";
1017
+ if (text.includes("무직") || text.includes("은퇴")) return "occ_retired";
1018
+ if (/(사무|경리|비서|회계|기획|행정|법률)/.test(text)) return "occ_office";
1019
+ if (/(서비스|판매|영업|상담|조리|주방|음식|청소|경비)/.test(text)) return "occ_service";
1020
+ if (/(전문|교사|교육|훈련|간호|의사|연구|마케팅)/.test(text)) return "occ_professional";
1021
+ if (/(기술|생산|운전|기계|전기|산업|안전|하역|적재|지게차|철도)/.test(text)) return "occ_technical";
1022
+ return null;
1023
+ }
1024
+
1025
+ function mapEducationCount(value: string): PersonaAttributeId | null {
1026
+ if (value.includes("대학원")) return "edu_graduate";
1027
+ if (value.includes("4년제")) return "edu_bachelor";
1028
+ if (value.includes("전문대") || value.includes("대학교")) return "edu_college";
1029
+ if (value.includes("고등") || value.includes("중학") || value.includes("초등") || value.includes("무학")) return "edu_high_school";
1030
+ return null;
1031
+ }
1032
+
1033
+ function dimensionLabel(dimension: PersonaDimensionId) {
1034
+ return PERSONA_DIMENSIONS.find((item) => item.id === dimension)?.label || dimension;
1035
+ }
1036
+
1037
+ function locationLabel(id: RegionId) {
1038
+ return LOCATION_OPTIONS.find((location) => location.id === id)?.label || id;
1039
+ }
1040
+
1041
+ function nemotronColumnLabel(column: string) {
1042
+ return NEMOTRON_COLUMN_LABELS[column] || column;
1043
+ }
frontend/src/silicon/data.ts ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AgeBandId, GenderId, LocationOption, PersonaOption, RegionId, RegionOption, SurveyQuestion, ToggleOption } from "./types";
2
+
3
+ export const GENDERS: ToggleOption<GenderId>[] = [
4
+ { id: "male", label: "남성", short: "남", defaultWeight: 49 },
5
+ { id: "female", label: "여성", short: "여", defaultWeight: 51 },
6
+ { id: "no_response", label: "응답 안 함", short: "무응답", defaultWeight: 1 },
7
+ ];
8
+
9
+ export const AGE_BANDS: ToggleOption<AgeBandId>[] = [
10
+ { id: "20s", label: "20대", short: "20", defaultWeight: 18 },
11
+ { id: "30s", label: "30대", short: "30", defaultWeight: 19 },
12
+ { id: "40s", label: "40대", short: "40", defaultWeight: 21 },
13
+ { id: "50s", label: "50대", short: "50", defaultWeight: 22 },
14
+ { id: "60plus", label: "60대 이상", short: "60+", defaultWeight: 20 },
15
+ ];
16
+
17
+ export const REGIONS: RegionOption[] = [
18
+ { id: "seoul", label: "서울", short: "서울", defaultWeight: 18, x: -0.28, y: 0.92, mapLabelX: 38, mapLabelY: 31 },
19
+ { id: "busan", label: "부산", short: "부산", defaultWeight: 7, x: 0.22, y: -0.75, mapLabelX: 61, mapLabelY: 77 },
20
+ { id: "daegu", label: "대구", short: "대구", defaultWeight: 5, x: 0.1, y: -0.18, mapLabelX: 58, mapLabelY: 61 },
21
+ { id: "incheon", label: "인천", short: "인천", defaultWeight: 6, x: -0.48, y: 0.83, mapLabelX: 31, mapLabelY: 34 },
22
+ { id: "gwangju", label: "광주", short: "광주", defaultWeight: 3, x: -0.43, y: -0.57, mapLabelX: 33, mapLabelY: 72 },
23
+ { id: "daejeon", label: "대전", short: "대전", defaultWeight: 3, x: -0.23, y: 0.2, mapLabelX: 41, mapLabelY: 51 },
24
+ { id: "ulsan", label: "울산", short: "울산", defaultWeight: 2, x: 0.31, y: -0.55, mapLabelX: 66, mapLabelY: 70 },
25
+ { id: "sejong", label: "세종", short: "세종", defaultWeight: 1, x: -0.3, y: 0.34, mapLabelX: 39, mapLabelY: 46 },
26
+ { id: "gyeonggi", label: "경기", short: "경기", defaultWeight: 26, x: -0.21, y: 0.72, mapLabelX: 42, mapLabelY: 38 },
27
+ { id: "gangwon", label: "강원", short: "강원", defaultWeight: 3, x: 0.1, y: 1.04, mapLabelX: 57, mapLabelY: 27 },
28
+ { id: "chungbuk", label: "충북", short: "충북", defaultWeight: 3, x: -0.08, y: 0.47, mapLabelX: 48, mapLabelY: 43 },
29
+ { id: "chungnam", label: "충남", short: "충남", defaultWeight: 4, x: -0.39, y: 0.32, mapLabelX: 34, mapLabelY: 48 },
30
+ { id: "jeonbuk", label: "전북", short: "전북", defaultWeight: 3, x: -0.31, y: -0.18, mapLabelX: 38, mapLabelY: 61 },
31
+ { id: "jeonnam", label: "전남", short: "전남", defaultWeight: 4, x: -0.42, y: -0.78, mapLabelX: 34, mapLabelY: 78 },
32
+ { id: "gyeongbuk", label: "경북", short: "경북", defaultWeight: 5, x: 0.13, y: 0.18, mapLabelX: 59, mapLabelY: 52 },
33
+ { id: "gyeongnam", label: "경남", short: "경남", defaultWeight: 6, x: 0.02, y: -0.64, mapLabelX: 53, mapLabelY: 73 },
34
+ { id: "jeju", label: "제주", short: "제주", defaultWeight: 1, x: -0.42, y: -1.54, mapLabelX: 33, mapLabelY: 92 },
35
+ ];
36
+
37
+ export const LOCATION_OPTIONS: LocationOption[] = [
38
+ ...REGIONS.map((region) => ({
39
+ id: region.id,
40
+ label: region.label,
41
+ short: region.short,
42
+ defaultWeight: region.defaultWeight,
43
+ parentRegion: region.id,
44
+ level: "sido" as const,
45
+ group: "시도",
46
+ })),
47
+ { id: "seoul_core", label: "서울 도심권", short: "서울도심", defaultWeight: 6, parentRegion: "seoul", level: "district", group: "서울 세부" },
48
+ { id: "seoul_gangnam", label: "서울 강남권", short: "강남권", defaultWeight: 7, parentRegion: "seoul", level: "district", group: "서울 세부" },
49
+ { id: "seoul_gangbuk", label: "서울 강북권", short: "강북권", defaultWeight: 5, parentRegion: "seoul", level: "district", group: "서울 세부" },
50
+ { id: "seoul_west", label: "서울 서남권", short: "서남권", defaultWeight: 5, parentRegion: "seoul", level: "district", group: "서울 세부" },
51
+ { id: "gyeonggi_south", label: "경기 남부", short: "경기남부", defaultWeight: 14, parentRegion: "gyeonggi", level: "district", group: "경기 세부" },
52
+ { id: "gyeonggi_north", label: "경기 북부", short: "경기북부", defaultWeight: 7, parentRegion: "gyeonggi", level: "district", group: "경기 세부" },
53
+ { id: "gyeonggi_west", label: "경기 서부", short: "경기서부", defaultWeight: 5, parentRegion: "gyeonggi", level: "district", group: "경기 세부" },
54
+ { id: "incheon_core", label: "인천 도심권", short: "인천도심", defaultWeight: 4, parentRegion: "incheon", level: "district", group: "광역시 세부" },
55
+ { id: "busan_core", label: "부산 도심권", short: "부산도심", defaultWeight: 4, parentRegion: "busan", level: "district", group: "광역시 세부" },
56
+ { id: "busan_east", label: "부산 동부권", short: "부산동부", defaultWeight: 3, parentRegion: "busan", level: "district", group: "광역시 세부" },
57
+ { id: "daegu_core", label: "대구권", short: "대구권", defaultWeight: 4, parentRegion: "daegu", level: "district", group: "광역시 세부" },
58
+ { id: "daejeon_core", label: "대전권", short: "대전권", defaultWeight: 3, parentRegion: "daejeon", level: "district", group: "광역시 세부" },
59
+ { id: "gwangju_core", label: "광주권", short: "광주권", defaultWeight: 3, parentRegion: "gwangju", level: "district", group: "광역시 세부" },
60
+ { id: "ulsan_core", label: "울산권", short: "울산권", defaultWeight: 2, parentRegion: "ulsan", level: "district", group: "광역시 세부" },
61
+ { id: "chungcheong_north", label: "충청 북부", short: "충청북부", defaultWeight: 3, parentRegion: "chungbuk", level: "district", group: "권역 세부" },
62
+ { id: "chungcheong_south", label: "충청 남부", short: "충청남부", defaultWeight: 4, parentRegion: "chungnam", level: "district", group: "권역 세부" },
63
+ { id: "jeolla_north", label: "전북권", short: "전북권", defaultWeight: 3, parentRegion: "jeonbuk", level: "district", group: "권역 세부" },
64
+ { id: "jeolla_south", label: "전남권", short: "전남권", defaultWeight: 4, parentRegion: "jeonnam", level: "district", group: "권역 세부" },
65
+ { id: "gyeongsang_north", label: "경북권", short: "경북권", defaultWeight: 5, parentRegion: "gyeongbuk", level: "district", group: "권역 세부" },
66
+ { id: "gyeongsang_south", label: "경남권", short: "경남권", defaultWeight: 6, parentRegion: "gyeongnam", level: "district", group: "권역 세부" },
67
+ { id: "gangwon_yeongseo", label: "강원 영서", short: "영서", defaultWeight: 2, parentRegion: "gangwon", level: "district", group: "강원/제주 세부" },
68
+ { id: "gangwon_yeongdong", label: "강원 영동", short: "영동", defaultWeight: 1, parentRegion: "gangwon", level: "district", group: "강원/제주 세부" },
69
+ { id: "jeju_core", label: "제주권", short: "제주권", defaultWeight: 1, parentRegion: "jeju", level: "district", group: "강원/제주 세부" },
70
+ ];
71
+
72
+ export const PERSONA_OPTIONS: PersonaOption[] = [
73
+ { id: "occ_office", label: "사무/관리직", short: "사무", defaultWeight: 18, dimension: "occupation", group: "직업" },
74
+ { id: "occ_service", label: "서비스/판매직", short: "서비스", defaultWeight: 16, dimension: "occupation", group: "직업" },
75
+ { id: "occ_professional", label: "전문직", short: "전문", defaultWeight: 13, dimension: "occupation", group: "직업" },
76
+ { id: "occ_self_employed", label: "자영업", short: "자영업", defaultWeight: 10, dimension: "occupation", group: "직업" },
77
+ { id: "occ_student", label: "학생", short: "학생", defaultWeight: 9, dimension: "occupation", group: "직업" },
78
+ { id: "occ_homemaker", label: "전업주부", short: "주부", defaultWeight: 9, dimension: "occupation", group: "직업" },
79
+ { id: "occ_technical", label: "기술/생산직", short: "기술", defaultWeight: 14, dimension: "occupation", group: "직업" },
80
+ { id: "occ_retired", label: "은퇴/무직", short: "은퇴", defaultWeight: 11, dimension: "occupation", group: "직업" },
81
+ { id: "edu_high_school", label: "고졸 이하", short: "고졸", defaultWeight: 34, dimension: "education", group: "학력" },
82
+ { id: "edu_college", label: "전문대/대학 재학", short: "대학", defaultWeight: 18, dimension: "education", group: "학력" },
83
+ { id: "edu_bachelor", label: "대졸", short: "대졸", defaultWeight: 39, dimension: "education", group: "학력" },
84
+ { id: "edu_graduate", label: "대학원 이상", short: "대학원", defaultWeight: 9, dimension: "education", group: "학력" },
85
+ { id: "housing_apartment", label: "아파트", short: "아파트", defaultWeight: 52, dimension: "housing", group: "주거" },
86
+ { id: "housing_house", label: "단독/다가구", short: "단독", defaultWeight: 24, dimension: "housing", group: "주거" },
87
+ { id: "housing_officetel", label: "오피스텔/원룸", short: "원룸", defaultWeight: 14, dimension: "housing", group: "주거" },
88
+ { id: "housing_other", label: "기타 주거", short: "기타", defaultWeight: 10, dimension: "housing", group: "주거" },
89
+ { id: "marital_single", label: "미혼", short: "미혼", defaultWeight: 34, dimension: "marital", group: "혼인" },
90
+ { id: "marital_married", label: "기혼", short: "기혼", defaultWeight: 57, dimension: "marital", group: "혼인" },
91
+ { id: "marital_divorced", label: "이혼/별거", short: "이혼", defaultWeight: 6, dimension: "marital", group: "혼인" },
92
+ { id: "marital_widowed", label: "사별", short: "사별", defaultWeight: 3, dimension: "marital", group: "혼인" },
93
+ { id: "family_single", label: "1인 가구", short: "1인", defaultWeight: 34, dimension: "family", group: "가구" },
94
+ { id: "family_couple", label: "부부 가구", short: "부부", defaultWeight: 22, dimension: "family", group: "가구" },
95
+ { id: "family_children", label: "자녀 동거", short: "자녀", defaultWeight: 34, dimension: "family", group: "가구" },
96
+ { id: "family_extended", label: "확대 가족", short: "확대", defaultWeight: 10, dimension: "family", group: "가구" },
97
+ ];
98
+
99
+ export const SURVEY_BANK: SurveyQuestion[] = [
100
+ {
101
+ id: "president_approval",
102
+ title: "현 정부 국정 운영을 어떻게 평가하십니까?",
103
+ source: "한국갤럽형 직무평가 문항",
104
+ category: "정치/국정",
105
+ kind: "likert",
106
+ scale: 4,
107
+ lowLabel: "매우 부정",
108
+ highLabel: "매우 긍정",
109
+ },
110
+ {
111
+ id: "party_trust",
112
+ title: "주요 정당이 국민 의견을 잘 반영한다고 보십니까?",
113
+ source: "선거 여론조사형 정당 신뢰 문항",
114
+ category: "정치/정당",
115
+ kind: "likert",
116
+ scale: 5,
117
+ lowLabel: "전혀 아니다",
118
+ highLabel: "매우 그렇다",
119
+ },
120
+ {
121
+ id: "economic_outlook",
122
+ title: "향후 1년 한국 경제가 좋아질 것이라고 보십니까?",
123
+ source: "경제전망 여론조사형 문항",
124
+ category: "경제",
125
+ kind: "likert",
126
+ scale: 5,
127
+ lowLabel: "매우 나빠짐",
128
+ highLabel: "매우 좋아짐",
129
+ },
130
+ {
131
+ id: "household_life",
132
+ title: "현재 가계 생활 형편에 얼마나 만족하십니까?",
133
+ source: "사회조사형 생활만족 문항",
134
+ category: "생활",
135
+ kind: "likert",
136
+ scale: 5,
137
+ lowLabel: "매우 불만족",
138
+ highLabel: "매우 만족",
139
+ },
140
+ {
141
+ id: "institution_trust",
142
+ title: "국회, 언론, 행정부 등 공공기관을 전반적으로 신뢰하십니까?",
143
+ source: "한국사회종합조사형 신뢰 문항",
144
+ category: "사회 신뢰",
145
+ kind: "likert",
146
+ scale: 5,
147
+ lowLabel: "전혀 신뢰 안 함",
148
+ highLabel: "매우 신뢰",
149
+ },
150
+ {
151
+ id: "low_birth_policy",
152
+ title: "저출산 대응 정책이 실제 삶에 도움이 된다고 보십니까?",
153
+ source: "정책 체감도 조사형 문항",
154
+ category: "정책",
155
+ kind: "likert",
156
+ scale: 5,
157
+ lowLabel: "전혀 도움 안 됨",
158
+ highLabel: "매우 도움 됨",
159
+ },
160
+ {
161
+ id: "climate_policy",
162
+ title: "탄소 감축을 위해 비용 부담이 늘어나는 정책에 동의하십니까?",
163
+ source: "환경 인식조사형 문항",
164
+ category: "환경",
165
+ kind: "likert",
166
+ scale: 5,
167
+ lowLabel: "전혀 동의 안 함",
168
+ highLabel: "매우 동의",
169
+ },
170
+ {
171
+ id: "news_reliability",
172
+ title: "온라인 뉴스와 유튜브 정치 정보를 신뢰하십니까?",
173
+ source: "미디어 이용조사형 문항",
174
+ category: "미디어",
175
+ kind: "likert",
176
+ scale: 5,
177
+ lowLabel: "전혀 신뢰 안 함",
178
+ highLabel: "매우 신뢰",
179
+ },
180
+ {
181
+ id: "top_social_issue",
182
+ title: "한국 사회가 가장 먼저 해결해야 할 문제는 무엇이라고 보십니까?",
183
+ source: "한국갤럽형 현안 자유응답",
184
+ category: "오픈엔드",
185
+ kind: "open",
186
+ },
187
+ {
188
+ id: "local_priority",
189
+ title: "거주 지역에서 가장 시급한 공공서비스 개선은 무엇입니까?",
190
+ source: "지방정부 만족도 자유응답",
191
+ category: "오픈엔드",
192
+ kind: "open",
193
+ },
194
+ {
195
+ id: "policy_request",
196
+ title: "새 정부나 지방자치단체에 가장 바라는 정책을 적어주십시오.",
197
+ source: "선거 후 정책 요구 자유응답",
198
+ category: "오픈엔드",
199
+ kind: "open",
200
+ },
201
+ ];
202
+
203
+ export const DEFAULT_QUESTION_IDS = ["president_approval", "economic_outlook", "institution_trust", "top_social_issue"];
frontend/src/silicon/simulate.ts ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AGE_BANDS, GENDERS, LOCATION_OPTIONS, PERSONA_OPTIONS, REGIONS } from "./data";
2
+ import type {
3
+ AgeBandId,
4
+ GenderId,
5
+ LikertAnswer,
6
+ LocationId,
7
+ OpenAnswer,
8
+ PersonaAttributeId,
9
+ PersonaDimensionId,
10
+ QuestionStat,
11
+ RegionId,
12
+ RegionStat,
13
+ SiliconConfig,
14
+ SiliconResult,
15
+ SurveyQuestion,
16
+ SyntheticRespondent,
17
+ WeightedPick,
18
+ } from "./types";
19
+
20
+ const REGION_TENDENCY: Record<RegionId, number> = {
21
+ seoul: 0.03,
22
+ busan: -0.02,
23
+ daegu: -0.08,
24
+ incheon: 0.01,
25
+ gwangju: 0.1,
26
+ daejeon: 0.02,
27
+ ulsan: -0.03,
28
+ sejong: 0.04,
29
+ gyeonggi: 0.02,
30
+ gangwon: -0.02,
31
+ chungbuk: -0.01,
32
+ chungnam: -0.02,
33
+ jeonbuk: 0.06,
34
+ jeonnam: 0.08,
35
+ gyeongbuk: -0.07,
36
+ gyeongnam: -0.04,
37
+ jeju: 0.05,
38
+ };
39
+
40
+ const AGE_TENDENCY: Record<AgeBandId, number> = {
41
+ "20s": 0.03,
42
+ "30s": 0.01,
43
+ "40s": 0,
44
+ "50s": -0.01,
45
+ "60plus": -0.02,
46
+ };
47
+
48
+ const THEMES = ["물가", "주거", "일자리", "돌봄", "교통", "의료", "지역경제", "교육", "기후", "정치 신뢰"];
49
+ const PERSONA_DIMENSIONS: PersonaDimensionId[] = ["occupation", "education", "housing", "marital", "family"];
50
+
51
+ export function defaultPicks<T extends string>(items: Array<{ id: T; defaultWeight: number }>): WeightedPick<T>[] {
52
+ return items.map((item) => ({ id: item.id, enabled: true, weight: item.defaultWeight }));
53
+ }
54
+
55
+ export function simulateSiliconSampling(config: SiliconConfig): SiliconResult {
56
+ const rand = mulberry32(config.seed);
57
+ const respondents: SyntheticRespondent[] = [];
58
+ const likertAnswers: LikertAnswer[] = [];
59
+ const openAnswers: OpenAnswer[] = [];
60
+ const likertQuestions = config.questions.filter((question) => question.kind === "likert");
61
+ const openQuestions = config.questions.filter((question) => question.kind === "open");
62
+ const genderPool = buildAllocationPool(config.genders, config.sampleSize, "female", rand);
63
+ const agePool = buildAllocationPool(config.ages, config.sampleSize, "40s", rand);
64
+ const locationPool = buildAllocationPool(config.locations, config.sampleSize, "seoul", rand);
65
+ const personaPools = buildPersonaPools(config.personaAttributes, config.sampleSize, rand);
66
+
67
+ for (let index = 0; index < config.sampleSize; index += 1) {
68
+ const gender = genderPool[index] || "female";
69
+ const age = agePool[index] || "40s";
70
+ const location = locationPool[index] || "seoul";
71
+ const locationOption = locationOptionOf(location, config.locationOptions);
72
+ const persona = personaAttributesFromPools(personaPools, index);
73
+ const respondent = buildRespondent(index, gender, age, locationOption.id, locationOption.parentRegion, locationOption.label, persona, rand);
74
+ respondents.push(respondent);
75
+ for (const question of likertQuestions) {
76
+ likertAnswers.push({
77
+ respondentId: respondent.id,
78
+ questionId: question.id,
79
+ value: answerLikert(question, respondent, rand),
80
+ });
81
+ }
82
+ for (const question of openQuestions) {
83
+ openAnswers.push({
84
+ respondentId: respondent.id,
85
+ questionId: question.id,
86
+ ...answerOpen(question, respondent, rand),
87
+ });
88
+ }
89
+ }
90
+
91
+ const primaryQuestionId = likertQuestions[0]?.id ?? null;
92
+ return {
93
+ config,
94
+ respondents,
95
+ likertAnswers,
96
+ openAnswers,
97
+ regionStats: buildRegionStats(config.locations, config.locationOptions, respondents, likertAnswers, openAnswers, likertQuestions[0]),
98
+ questionStats: buildQuestionStats(config.questions, likertAnswers),
99
+ primaryQuestionId,
100
+ };
101
+ }
102
+
103
+ function buildRespondent(
104
+ index: number,
105
+ gender: GenderId,
106
+ age: AgeBandId,
107
+ location: LocationId,
108
+ region: RegionId,
109
+ locationLabel: string,
110
+ persona: ReturnType<typeof pickPersonaAttributes>,
111
+ rand: () => number,
112
+ ): SyntheticRespondent {
113
+ const regionBias = REGION_TENDENCY[region] || 0;
114
+ const ageBias = AGE_TENDENCY[age] || 0;
115
+ const genderBias = gender === "female" ? 0.015 : gender === "male" ? -0.01 : 0;
116
+ const personaBias = personaBiases(persona.attributes);
117
+ const economicAnxiety = clamp01(0.5 + (age === "30s" || age === "40s" ? 0.08 : 0) + (region === "seoul" || region === "gyeonggi" ? 0.04 : 0) + personaBias.anxiety + noise(rand, 0.18));
118
+ const trust = clamp01(0.48 + regionBias + ageBias + genderBias + personaBias.trust - economicAnxiety * 0.08 + noise(rand, 0.16));
119
+ const participation = clamp01(0.5 + (age === "60plus" ? 0.12 : 0) + (age === "20s" ? -0.06 : 0) + Math.abs(regionBias) * 0.4 + personaBias.participation + noise(rand, 0.14));
120
+ return {
121
+ id: `R${String(index + 1).padStart(4, "0")}`,
122
+ gender,
123
+ age,
124
+ region,
125
+ location,
126
+ locationLabel,
127
+ personaAttributes: persona.attributes,
128
+ personaLabels: persona.labels,
129
+ segment: segmentLabel(trust, economicAnxiety, participation),
130
+ trust,
131
+ economicAnxiety,
132
+ participation,
133
+ };
134
+ }
135
+
136
+ function answerLikert(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number) {
137
+ const scale = question.scale || 5;
138
+ let score = scale / 2 + 0.5;
139
+ const trustTerm = (respondent.trust - 0.5) * scale * 0.8;
140
+ const anxietyTerm = (respondent.economicAnxiety - 0.5) * scale * 0.58;
141
+ const participationTerm = (respondent.participation - 0.5) * scale * 0.32;
142
+ const regionTerm = (REGION_TENDENCY[respondent.region] || 0) * scale;
143
+
144
+ if (question.id.includes("approval") || question.id.includes("trust")) score += trustTerm + regionTerm;
145
+ else if (question.id.includes("economic") || question.id.includes("household")) score += -anxietyTerm + trustTerm * 0.28;
146
+ else if (question.id.includes("climate")) score += participationTerm + (respondent.age === "20s" ? 0.35 : 0) - (respondent.age === "60plus" ? 0.18 : 0);
147
+ else if (question.id.includes("birth")) score += -anxietyTerm * 0.35 + (respondent.age === "30s" ? -0.18 : 0.08);
148
+ else if (question.id.includes("news")) score += trustTerm * 0.35 - (respondent.age === "20s" ? 0.1 : 0) + (respondent.age === "60plus" ? 0.15 : 0);
149
+ else score += trustTerm * 0.35 - anxietyTerm * 0.18;
150
+
151
+ score += noise(rand, scale * 0.36);
152
+ return Math.max(1, Math.min(scale, Math.round(score)));
153
+ }
154
+
155
+ function answerOpen(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { text: string; theme: string } {
156
+ const region = labelOf(REGIONS, respondent.region);
157
+ const age = labelOf(AGE_BANDS, respondent.age);
158
+ const occupation = respondent.personaLabels.occupation ? ` ${respondent.personaLabels.occupation}` : "";
159
+ let theme = THEMES[Math.floor(rand() * THEMES.length)];
160
+ if (respondent.economicAnxiety > 0.68) theme = rand() > 0.5 ? "물가" : "주거";
161
+ if (respondent.age === "20s" || respondent.age === "30s") theme = rand() > 0.45 ? "일자리" : "주거";
162
+ if (respondent.age === "60plus") theme = rand() > 0.5 ? "의료" : "돌봄";
163
+ if (question.id.includes("local")) theme = rand() > 0.5 ? "교통" : "지역경제";
164
+ if (question.id.includes("policy")) theme = rand() > 0.5 ? "주거" : "정치 신뢰";
165
+
166
+ const tone = respondent.trust > 0.58 ? "지금보다 체감 가능한 방식으로 확대되면 좋겠습니다" : "구호보다 실제 집행과 설명이 먼저 필요합니다";
167
+ const text = `${region} 거주 ${age}${occupation} 응답자로서 ${theme} 문제가 가장 크게 느껴집니다. ${tone}.`;
168
+ return { theme, text };
169
+ }
170
+
171
+ function buildRegionStats(
172
+ locations: WeightedPick<LocationId>[],
173
+ locationOptions: SiliconConfig["locationOptions"],
174
+ respondents: SyntheticRespondent[],
175
+ likertAnswers: LikertAnswer[],
176
+ openAnswers: OpenAnswer[],
177
+ primaryQuestion?: SurveyQuestion,
178
+ ): RegionStat[] {
179
+ const primaryQuestionId = primaryQuestion?.id ?? null;
180
+ const scale = primaryQuestion?.scale || 5;
181
+ const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
182
+ const enabledLocations = locations.filter((location) => location.enabled && location.weight > 0);
183
+ return enabledLocations.map((locationPick) => {
184
+ const option = locationOptionOf(locationPick.id, locationOptions);
185
+ const people = respondents.filter((respondent) => respondent.location === option.id);
186
+ const ids = new Set(people.map((respondent) => respondent.id));
187
+ const answers = primaryQuestionId ? likertAnswers.filter((answer) => answer.questionId === primaryQuestionId && ids.has(answer.respondentId)) : [];
188
+ return {
189
+ region: option.id,
190
+ parentRegion: option.parentRegion,
191
+ label: option.label,
192
+ respondents: people.length,
193
+ mean: mean(answers.map((answer) => answer.value)),
194
+ scale,
195
+ positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0,
196
+ openCount: openAnswers.filter((answer) => ids.has(answer.respondentId)).length,
197
+ };
198
+ });
199
+ }
200
+
201
+ function buildQuestionStats(questions: SurveyQuestion[], likertAnswers: LikertAnswer[]): QuestionStat[] {
202
+ return questions.map((question) => {
203
+ if (question.kind === "open") return { questionId: question.id, title: question.title, kind: "open" };
204
+ const scale = question.scale || 5;
205
+ const answers = likertAnswers.filter((answer) => answer.questionId === question.id);
206
+ const distribution = Array.from({ length: scale }, (_, index) => {
207
+ const value = index + 1;
208
+ const count = answers.filter((answer) => answer.value === value).length;
209
+ return { value, count, share: answers.length ? count / answers.length : 0 };
210
+ });
211
+ const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
212
+ return {
213
+ questionId: question.id,
214
+ title: question.title,
215
+ kind: "likert",
216
+ scale,
217
+ mean: mean(answers.map((answer) => answer.value)),
218
+ positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0,
219
+ distribution,
220
+ };
221
+ });
222
+ }
223
+
224
+ export function groupBreakdown(result: SiliconResult, dimension: "gender" | "age") {
225
+ const primary = result.primaryQuestionId;
226
+ if (!primary) return [];
227
+ const answerByRespondent = new Map(result.likertAnswers.filter((answer) => answer.questionId === primary).map((answer) => [answer.respondentId, answer.value]));
228
+ const options = dimension === "gender" ? GENDERS : AGE_BANDS;
229
+ return options.map((option) => {
230
+ const people = result.respondents.filter((respondent) => respondent[dimension] === option.id);
231
+ const values = people.map((respondent) => answerByRespondent.get(respondent.id)).filter((value): value is number => typeof value === "number");
232
+ return {
233
+ id: option.id,
234
+ label: option.label,
235
+ respondents: people.length,
236
+ mean: mean(values),
237
+ };
238
+ });
239
+ }
240
+
241
+ export function resultBreakdown(result: SiliconResult, questionId: string, dimension: "gender" | "age" | "region" | PersonaDimensionId) {
242
+ const question = result.config.questions.find((item) => item.id === questionId);
243
+ if (!question || question.kind !== "likert") return [];
244
+ const answers = result.likertAnswers.filter((answer) => answer.questionId === questionId);
245
+ const valuesByRespondent = new Map(answers.map((answer) => [answer.respondentId, answer.value]));
246
+ const scale = question.scale || 5;
247
+ const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
248
+ const options = dimension === "gender"
249
+ ? GENDERS.map((option) => ({ id: option.id, label: option.label }))
250
+ : dimension === "age"
251
+ ? AGE_BANDS.map((option) => ({ id: option.id, label: option.label }))
252
+ : dimension === "region"
253
+ ? result.regionStats.map((option) => ({ id: option.region, label: option.label }))
254
+ : personaBreakdownOptions(result, dimension);
255
+ return options.map((option) => {
256
+ const id = option.id;
257
+ const label = option.label;
258
+ const people = result.respondents.filter((respondent) => {
259
+ if (dimension === "gender") return respondent.gender === id;
260
+ if (dimension === "age") return respondent.age === id;
261
+ if (dimension === "region") return respondent.location === id;
262
+ return respondent.personaAttributes[dimension] === id;
263
+ });
264
+ const values = people.map((respondent) => valuesByRespondent.get(respondent.id)).filter((value): value is number => typeof value === "number");
265
+ return {
266
+ id,
267
+ label,
268
+ respondents: people.length,
269
+ mean: mean(values),
270
+ positiveShare: values.length ? values.filter((value) => value >= positiveCut).length / values.length : 0,
271
+ };
272
+ }).filter((item) => item.respondents > 0);
273
+ }
274
+
275
+ function personaBreakdownOptions(result: SiliconResult, dimension: PersonaDimensionId) {
276
+ const rows = new Map<string, { id: string; label: string }>();
277
+ for (const respondent of result.respondents) {
278
+ const id = respondent.personaAttributes[dimension];
279
+ if (!id) continue;
280
+ rows.set(id, { id, label: respondent.personaLabels[dimension] || id });
281
+ }
282
+ return [...rows.values()];
283
+ }
284
+
285
+ export function selectedWeightTotal<T extends string>(picks: WeightedPick<T>[]) {
286
+ return picks.filter((pick) => pick.enabled).reduce((total, pick) => total + Math.max(0, pick.weight), 0);
287
+ }
288
+
289
+ function pickWeighted<T extends string>(items: WeightedPick<T>[], rand: () => number, fallback: T): T {
290
+ const enabled = items.filter((item) => item.enabled && item.weight > 0);
291
+ const total = selectedWeightTotal(enabled);
292
+ if (!enabled.length || total <= 0) return fallback;
293
+ let cursor = rand() * total;
294
+ for (const item of enabled) {
295
+ cursor -= item.weight;
296
+ if (cursor <= 0) return item.id;
297
+ }
298
+ return enabled[enabled.length - 1].id;
299
+ }
300
+
301
+ function buildAllocationPool<T extends string>(items: WeightedPick<T>[], targetSize: number, fallback: T, rand: () => number): T[] {
302
+ const enabled = items.filter((item) => item.enabled && item.weight > 0);
303
+ if (!enabled.length) return Array.from({ length: targetSize }, () => fallback);
304
+ const allocations = allocatePickCounts(enabled, targetSize);
305
+ const pool: T[] = [];
306
+ for (const item of enabled) {
307
+ const count = allocations.get(item.id) || 0;
308
+ for (let index = 0; index < count; index += 1) pool.push(item.id);
309
+ }
310
+ while (pool.length < targetSize) pool.push(fallback);
311
+ shuffle(pool, rand);
312
+ return pool.slice(0, targetSize);
313
+ }
314
+
315
+ function allocatePickCounts<T extends string>(items: WeightedPick<T>[], targetSize: number) {
316
+ const total = items.reduce((sum, item) => sum + Math.max(0, item.weight), 0);
317
+ if (total <= 0) return new Map<T, number>();
318
+ const rows = items.map((item) => {
319
+ const raw = Math.max(0, item.weight) / total * targetSize;
320
+ return { id: item.id, floor: Math.floor(raw), remainder: raw - Math.floor(raw) };
321
+ });
322
+ let used = rows.reduce((sum, row) => sum + row.floor, 0);
323
+ for (const row of rows.sort((a, b) => b.remainder - a.remainder)) {
324
+ if (used >= targetSize) break;
325
+ row.floor += 1;
326
+ used += 1;
327
+ }
328
+ return new Map(rows.map((row) => [row.id, row.floor]));
329
+ }
330
+
331
+ function shuffle<T>(items: T[], rand: () => number) {
332
+ for (let index = items.length - 1; index > 0; index -= 1) {
333
+ const swapIndex = Math.floor(rand() * (index + 1));
334
+ [items[index], items[swapIndex]] = [items[swapIndex], items[index]];
335
+ }
336
+ }
337
+
338
+ function locationOptionOf(id: LocationId, options: SiliconConfig["locationOptions"] = LOCATION_OPTIONS) {
339
+ return options.find((location) => location.id === id) || LOCATION_OPTIONS.find((location) => location.id === "seoul")!;
340
+ }
341
+
342
+ function pickPersonaAttributes(items: WeightedPick<PersonaAttributeId>[], rand: () => number) {
343
+ const attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>> = {};
344
+ const labels: Partial<Record<PersonaDimensionId, string>> = {};
345
+ for (const dimension of PERSONA_DIMENSIONS) {
346
+ const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension);
347
+ const picks = items.filter((item) => options.some((option) => option.id === item.id));
348
+ const picked = pickWeighted(picks, rand, "" as PersonaAttributeId);
349
+ if (!picked) continue;
350
+ const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked);
351
+ if (!option) continue;
352
+ attributes[dimension] = option.id;
353
+ labels[dimension] = option.label;
354
+ }
355
+ return { attributes, labels };
356
+ }
357
+
358
+ function buildPersonaPools(items: WeightedPick<PersonaAttributeId>[], targetSize: number, rand: () => number) {
359
+ const pools = new Map<PersonaDimensionId, PersonaAttributeId[]>();
360
+ for (const dimension of PERSONA_DIMENSIONS) {
361
+ const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension);
362
+ const picks = items.filter((item) => options.some((option) => option.id === item.id));
363
+ pools.set(dimension, buildAllocationPool(picks, targetSize, "" as PersonaAttributeId, rand));
364
+ }
365
+ return pools;
366
+ }
367
+
368
+ function personaAttributesFromPools(pools: Map<PersonaDimensionId, PersonaAttributeId[]>, index: number) {
369
+ const attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>> = {};
370
+ const labels: Partial<Record<PersonaDimensionId, string>> = {};
371
+ for (const dimension of PERSONA_DIMENSIONS) {
372
+ const picked = pools.get(dimension)?.[index];
373
+ if (!picked) continue;
374
+ const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked);
375
+ if (!option) continue;
376
+ attributes[dimension] = option.id;
377
+ labels[dimension] = option.label;
378
+ }
379
+ return { attributes, labels };
380
+ }
381
+
382
+ function personaBiases(attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>>) {
383
+ let trust = 0;
384
+ let anxiety = 0;
385
+ let participation = 0;
386
+ if (attributes.occupation === "occ_self_employed") anxiety += 0.05;
387
+ if (attributes.occupation === "occ_student") participation += 0.03;
388
+ if (attributes.occupation === "occ_retired") participation += 0.04;
389
+ if (attributes.occupation === "occ_professional") trust += 0.02;
390
+ if (attributes.education === "edu_graduate" || attributes.education === "edu_bachelor") participation += 0.02;
391
+ if (attributes.housing === "housing_officetel") anxiety += 0.03;
392
+ if (attributes.family === "family_children") anxiety += 0.02;
393
+ if (attributes.family === "family_single") participation -= 0.01;
394
+ return { trust, anxiety, participation };
395
+ }
396
+
397
+ function labelOf<T extends string>(items: Array<{ id: T; label: string }>, id: T) {
398
+ return items.find((item) => item.id === id)?.label || id;
399
+ }
400
+
401
+ function segmentLabel(trust: number, anxiety: number, participation: number) {
402
+ if (anxiety > 0.68) return "생활압박층";
403
+ if (trust > 0.6 && participation > 0.55) return "제도참여층";
404
+ if (trust < 0.42) return "불신/관망층";
405
+ if (participation > 0.64) return "고관여층";
406
+ return "중도실용층";
407
+ }
408
+
409
+ function mean(values: number[]) {
410
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
411
+ }
412
+
413
+ function noise(rand: () => number, span: number) {
414
+ return (rand() - 0.5) * span * 2;
415
+ }
416
+
417
+ function clamp01(value: number) {
418
+ return Math.max(0, Math.min(1, value));
419
+ }
420
+
421
+ function mulberry32(seed: number) {
422
+ let state = seed >>> 0;
423
+ return () => {
424
+ state += 0x6d2b79f5;
425
+ let t = state;
426
+ t = Math.imul(t ^ (t >>> 15), t | 1);
427
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
428
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
429
+ };
430
+ }
frontend/src/silicon/types.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type GenderId = "male" | "female" | "no_response";
2
+ export type AgeBandId = "20s" | "30s" | "40s" | "50s" | "60plus";
3
+ export type RegionId =
4
+ | "seoul"
5
+ | "busan"
6
+ | "daegu"
7
+ | "incheon"
8
+ | "gwangju"
9
+ | "daejeon"
10
+ | "ulsan"
11
+ | "sejong"
12
+ | "gyeonggi"
13
+ | "gangwon"
14
+ | "chungbuk"
15
+ | "chungnam"
16
+ | "jeonbuk"
17
+ | "jeonnam"
18
+ | "gyeongbuk"
19
+ | "gyeongnam"
20
+ | "jeju";
21
+ export type LocationId = string;
22
+ export type LocationLevel = "sido" | "district";
23
+ export type PersonaDimensionId = "occupation" | "education" | "housing" | "marital" | "family";
24
+ export type PersonaAttributeId = string;
25
+
26
+ export type QuestionKind = "likert" | "open";
27
+
28
+ export interface ToggleOption<T extends string> {
29
+ id: T;
30
+ label: string;
31
+ short: string;
32
+ defaultWeight: number;
33
+ }
34
+
35
+ export interface RegionOption extends ToggleOption<RegionId> {
36
+ x: number;
37
+ y: number;
38
+ mapLabelX: number;
39
+ mapLabelY: number;
40
+ }
41
+
42
+ export interface LocationOption extends ToggleOption<LocationId> {
43
+ parentRegion: RegionId;
44
+ level: LocationLevel;
45
+ group: string;
46
+ }
47
+
48
+ export interface PersonaOption extends ToggleOption<PersonaAttributeId> {
49
+ dimension: PersonaDimensionId;
50
+ group: string;
51
+ }
52
+
53
+ export interface SurveyQuestion {
54
+ id: string;
55
+ title: string;
56
+ source: string;
57
+ category: string;
58
+ kind: QuestionKind;
59
+ scale?: 4 | 5 | 7;
60
+ lowLabel?: string;
61
+ highLabel?: string;
62
+ }
63
+
64
+ export interface WeightedPick<T extends string> {
65
+ id: T;
66
+ enabled: boolean;
67
+ weight: number;
68
+ }
69
+
70
+ export interface SiliconConfig {
71
+ sampleSize: number;
72
+ genders: WeightedPick<GenderId>[];
73
+ ages: WeightedPick<AgeBandId>[];
74
+ locations: WeightedPick<LocationId>[];
75
+ locationOptions: LocationOption[];
76
+ personaAttributes: WeightedPick<PersonaAttributeId>[];
77
+ nemotronFields: string[];
78
+ questions: SurveyQuestion[];
79
+ seed: number;
80
+ }
81
+
82
+ export interface SyntheticRespondent {
83
+ id: string;
84
+ gender: GenderId;
85
+ age: AgeBandId;
86
+ region: RegionId;
87
+ location: LocationId;
88
+ locationLabel: string;
89
+ personaAttributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>>;
90
+ personaLabels: Partial<Record<PersonaDimensionId, string>>;
91
+ segment: string;
92
+ trust: number;
93
+ economicAnxiety: number;
94
+ participation: number;
95
+ }
96
+
97
+ export interface LikertAnswer {
98
+ respondentId: string;
99
+ questionId: string;
100
+ value: number;
101
+ }
102
+
103
+ export interface OpenAnswer {
104
+ respondentId: string;
105
+ questionId: string;
106
+ text: string;
107
+ theme: string;
108
+ }
109
+
110
+ export interface RegionStat {
111
+ region: LocationId;
112
+ parentRegion: RegionId;
113
+ label: string;
114
+ respondents: number;
115
+ mean: number;
116
+ scale: number;
117
+ positiveShare: number;
118
+ openCount: number;
119
+ }
120
+
121
+ export interface QuestionStat {
122
+ questionId: string;
123
+ title: string;
124
+ kind: QuestionKind;
125
+ scale?: number;
126
+ mean?: number;
127
+ positiveShare?: number;
128
+ distribution?: Array<{ value: number; count: number; share: number }>;
129
+ }
130
+
131
+ export interface SiliconResult {
132
+ config: SiliconConfig;
133
+ respondents: SyntheticRespondent[];
134
+ likertAnswers: LikertAnswer[];
135
+ openAnswers: OpenAnswer[];
136
+ regionStats: RegionStat[];
137
+ questionStats: QuestionStat[];
138
+ primaryQuestionId: string | null;
139
+ }
frontend/src/styles.css ADDED
@@ -0,0 +1,1298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ font-family: "Aptos", "IBM Plex Sans KR", "IBM Plex Sans", "Helvetica Neue", sans-serif;
4
+ background: #f8fafc;
5
+ color: #18213a;
6
+ text-rendering: geometricPrecision;
7
+ font-synthesis: none;
8
+ --ink: #07100f;
9
+ --panel: rgba(6, 13, 13, 0.42);
10
+ --panel-strong: rgba(6, 13, 13, 0.58);
11
+ --line: rgba(237, 244, 231, 0.13);
12
+ --text: #f6f1e6;
13
+ --muted: rgba(246, 241, 230, 0.62);
14
+ --soft: rgba(246, 241, 230, 0.08);
15
+ --accent: #78d6b4;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ html,
23
+ body,
24
+ #root {
25
+ width: 100%;
26
+ min-width: 0;
27
+ min-height: 100%;
28
+ margin: 0;
29
+ }
30
+
31
+ body {
32
+ overflow-x: hidden;
33
+ }
34
+
35
+ button,
36
+ a {
37
+ font: inherit;
38
+ -webkit-tap-highlight-color: transparent;
39
+ }
40
+
41
+ button {
42
+ cursor: pointer;
43
+ }
44
+ .silicon-shell {
45
+ min-height: 100vh;
46
+ display: grid;
47
+ grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
48
+ background:
49
+ radial-gradient(circle at 70% 8%, rgba(127, 156, 255, 0.14), transparent 34%),
50
+ radial-gradient(circle at 20% 78%, rgba(217, 162, 79, 0.1), transparent 28%),
51
+ linear-gradient(135deg, #101017 0%, #171722 48%, #14151d 100%);
52
+ color: #f6f1e6;
53
+ }
54
+
55
+ .silicon-shell.setup-only {
56
+ display: block;
57
+ }
58
+
59
+ .silicon-shell.setup-only .silicon-config {
60
+ position: relative;
61
+ height: auto;
62
+ max-width: 1320px;
63
+ margin: 0 auto;
64
+ border-right: 0;
65
+ background: transparent;
66
+ }
67
+
68
+ .silicon-setup-grid {
69
+ display: block;
70
+ }
71
+
72
+ .silicon-shell.setup-only .silicon-setup-grid {
73
+ display: grid;
74
+ grid-template-columns: repeat(3, minmax(0, 1fr));
75
+ gap: 12px;
76
+ align-items: start;
77
+ }
78
+
79
+ .silicon-shell.setup-only .silicon-card {
80
+ margin-top: 0;
81
+ }
82
+
83
+ .silicon-shell.setup-only .silicon-hero {
84
+ margin-bottom: 12px;
85
+ }
86
+
87
+ .silicon-shell.setup-only .location-card,
88
+ .silicon-shell.setup-only .persona-card,
89
+ .silicon-shell.setup-only .nemotron-card,
90
+ .silicon-shell.setup-only .question-card {
91
+ grid-column: span 3;
92
+ }
93
+
94
+ .silicon-config {
95
+ position: sticky;
96
+ top: 0;
97
+ height: 100vh;
98
+ overflow: auto;
99
+ border-right: 1px solid rgba(246, 241, 230, 0.11);
100
+ padding: 18px;
101
+ background: rgba(13, 13, 20, 0.66);
102
+ }
103
+
104
+ .silicon-hero,
105
+ .silicon-card,
106
+ .silicon-result-head,
107
+ .silicon-summary-panel,
108
+ .silicon-chart-card,
109
+ .silicon-open-panel {
110
+ border: 1px solid rgba(246, 241, 230, 0.12);
111
+ border-radius: 18px;
112
+ background: rgba(246, 241, 230, 0.055);
113
+ box-shadow: 0 20px 70px rgba(0, 0, 0, 0.22);
114
+ backdrop-filter: blur(18px) saturate(1.1);
115
+ }
116
+
117
+ .silicon-hero {
118
+ padding: 16px;
119
+ }
120
+
121
+ .silicon-brand {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ gap: 10px;
125
+ color: #f6f1e6;
126
+ text-decoration: none;
127
+ }
128
+
129
+ .silicon-brand span {
130
+ width: 34px;
131
+ height: 34px;
132
+ border: 1px solid rgba(246, 241, 230, 0.22);
133
+ border-radius: 10px;
134
+ background:
135
+ linear-gradient(90deg, transparent 45%, rgba(246, 241, 230, .5) 46%, rgba(246, 241, 230, .5) 54%, transparent 55%),
136
+ linear-gradient(0deg, transparent 45%, rgba(246, 241, 230, .5) 46%, rgba(246, 241, 230, .5) 54%, transparent 55%),
137
+ linear-gradient(135deg, #7f9cff, #d9a24f);
138
+ }
139
+
140
+ .silicon-hero h1 {
141
+ margin: 18px 0 8px;
142
+ font-size: clamp(30px, 4vw, 48px);
143
+ line-height: 0.96;
144
+ letter-spacing: 0;
145
+ }
146
+
147
+ .silicon-hero p {
148
+ margin: 0;
149
+ color: rgba(246, 241, 230, 0.66);
150
+ line-height: 1.45;
151
+ }
152
+
153
+ .silicon-actions,
154
+ .sample-presets,
155
+ .custom-kind,
156
+ .result-stats {
157
+ display: flex;
158
+ flex-wrap: wrap;
159
+ gap: 8px;
160
+ }
161
+
162
+ .silicon-actions {
163
+ margin-top: 16px;
164
+ }
165
+
166
+ .silicon-actions button,
167
+ .sample-presets button,
168
+ .custom-kind button,
169
+ .custom-question > button {
170
+ min-height: 38px;
171
+ border: 1px solid rgba(246, 241, 230, 0.13);
172
+ border-radius: 999px;
173
+ padding: 8px 12px;
174
+ background: rgba(246, 241, 230, 0.08);
175
+ color: #f6f1e6;
176
+ font-weight: 850;
177
+ }
178
+
179
+ .silicon-actions .primary,
180
+ .sample-presets button.active,
181
+ .custom-kind button.active {
182
+ border-color: rgba(127, 156, 255, 0.68);
183
+ background: rgba(127, 156, 255, 0.2);
184
+ }
185
+
186
+ .silicon-actions button:disabled {
187
+ cursor: not-allowed;
188
+ opacity: 0.52;
189
+ }
190
+
191
+ .silicon-run-chip {
192
+ margin-top: 12px;
193
+ border: 1px solid rgba(246, 241, 230, 0.11);
194
+ border-radius: 12px;
195
+ padding: 9px 10px;
196
+ background: rgba(246, 241, 230, 0.05);
197
+ color: rgba(246, 241, 230, 0.68);
198
+ font-size: 12px;
199
+ line-height: 1.35;
200
+ }
201
+
202
+ .silicon-run-chip.running {
203
+ border-color: rgba(217, 162, 79, 0.5);
204
+ background: rgba(217, 162, 79, 0.12);
205
+ color: rgba(246, 241, 230, 0.86);
206
+ }
207
+
208
+ .silicon-card {
209
+ margin-top: 12px;
210
+ padding: 14px;
211
+ }
212
+
213
+ .default-ratio-box {
214
+ display: grid;
215
+ gap: 8px;
216
+ margin-top: 12px;
217
+ border-top: 1px solid rgba(246, 241, 230, 0.09);
218
+ padding-top: 12px;
219
+ }
220
+
221
+ .default-ratio-box button {
222
+ min-height: 38px;
223
+ border: 1px solid rgba(217, 162, 79, 0.42);
224
+ border-radius: 999px;
225
+ padding: 8px 12px;
226
+ background: rgba(217, 162, 79, 0.13);
227
+ color: #f6f1e6;
228
+ font-weight: 850;
229
+ }
230
+
231
+ .default-ratio-box span {
232
+ color: rgba(246, 241, 230, 0.62);
233
+ font-size: 12px;
234
+ line-height: 1.4;
235
+ }
236
+
237
+ .silicon-section-head,
238
+ .silicon-summary-panel header {
239
+ display: flex;
240
+ align-items: center;
241
+ gap: 10px;
242
+ }
243
+
244
+ .question-card .silicon-section-head {
245
+ justify-content: space-between;
246
+ }
247
+
248
+ .silicon-section-head span,
249
+ .toggle-head span,
250
+ .silicon-result-head span,
251
+ .silicon-summary-panel header span,
252
+ .silicon-chart-card header span,
253
+ .silicon-open-panel header span {
254
+ display: block;
255
+ color: rgba(246, 241, 230, 0.56);
256
+ font-size: 11px;
257
+ font-weight: 850;
258
+ letter-spacing: 0.07em;
259
+ text-transform: uppercase;
260
+ }
261
+
262
+ .silicon-section-head strong,
263
+ .toggle-head strong,
264
+ .silicon-summary-panel header strong,
265
+ .silicon-chart-card header strong,
266
+ .silicon-open-panel header strong {
267
+ display: block;
268
+ margin-top: 2px;
269
+ font-size: 16px;
270
+ }
271
+
272
+ .sample-presets {
273
+ margin-top: 12px;
274
+ }
275
+
276
+ .number-field,
277
+ .custom-question label {
278
+ display: grid;
279
+ gap: 6px;
280
+ margin-top: 12px;
281
+ }
282
+
283
+ .number-field span,
284
+ .custom-question label span {
285
+ color: rgba(246, 241, 230, 0.58);
286
+ font-size: 11px;
287
+ font-weight: 800;
288
+ }
289
+
290
+ .number-field input,
291
+ .toggle-chip input,
292
+ .custom-question input,
293
+ .custom-kind select {
294
+ min-width: 0;
295
+ border: 1px solid rgba(246, 241, 230, 0.13);
296
+ border-radius: 10px;
297
+ background: rgba(5, 12, 12, 0.62);
298
+ color: #f6f1e6;
299
+ padding: 9px 10px;
300
+ outline: none;
301
+ }
302
+
303
+ .number-field input:focus,
304
+ .toggle-chip input:focus,
305
+ .custom-question input:focus,
306
+ .custom-kind select:focus {
307
+ border-color: rgba(127, 156, 255, 0.68);
308
+ }
309
+
310
+ .toggle-head {
311
+ display: flex;
312
+ align-items: flex-start;
313
+ justify-content: space-between;
314
+ gap: 10px;
315
+ }
316
+
317
+ .toggle-head em {
318
+ border: 1px solid rgba(246, 241, 230, 0.12);
319
+ border-radius: 999px;
320
+ padding: 5px 8px;
321
+ color: rgba(246, 241, 230, 0.64);
322
+ font-size: 11px;
323
+ font-style: normal;
324
+ font-weight: 850;
325
+ }
326
+
327
+ .toggle-head em.warn {
328
+ border-color: rgba(217, 162, 79, 0.42);
329
+ color: rgba(246, 241, 230, 0.78);
330
+ }
331
+
332
+ .toggle-grid {
333
+ display: grid;
334
+ grid-template-columns: repeat(2, minmax(0, 1fr));
335
+ gap: 8px;
336
+ margin-top: 12px;
337
+ }
338
+
339
+ .toggle-grid.compact {
340
+ grid-template-columns: repeat(3, minmax(0, 1fr));
341
+ }
342
+
343
+ .toggle-chip {
344
+ display: grid;
345
+ grid-template-columns: minmax(0, 1fr) 54px;
346
+ gap: 6px;
347
+ align-items: center;
348
+ border: 1px solid rgba(246, 241, 230, 0.1);
349
+ border-radius: 12px;
350
+ padding: 6px;
351
+ background: rgba(246, 241, 230, 0.045);
352
+ }
353
+
354
+ .toggle-chip.active {
355
+ border-color: rgba(127, 156, 255, 0.5);
356
+ background: rgba(127, 156, 255, 0.13);
357
+ }
358
+
359
+ .toggle-chip button {
360
+ min-height: 34px;
361
+ border: 0;
362
+ border-radius: 9px;
363
+ background: transparent;
364
+ color: #f6f1e6;
365
+ font-weight: 850;
366
+ text-align: left;
367
+ }
368
+
369
+ .toggle-chip input {
370
+ height: 34px;
371
+ padding: 6px;
372
+ text-align: center;
373
+ }
374
+
375
+ .toggle-chip input:disabled {
376
+ opacity: 0.34;
377
+ }
378
+
379
+ .estimate-count {
380
+ grid-column: 1 / -1;
381
+ min-height: 14px;
382
+ color: rgba(246, 241, 230, 0.56);
383
+ font-size: 11px;
384
+ font-weight: 780;
385
+ }
386
+
387
+ .question-bank {
388
+ display: grid;
389
+ gap: 8px;
390
+ margin-top: 12px;
391
+ }
392
+
393
+ .question-bank button {
394
+ display: grid;
395
+ gap: 4px;
396
+ border: 1px solid rgba(246, 241, 230, 0.1);
397
+ border-radius: 12px;
398
+ background: rgba(246, 241, 230, 0.045);
399
+ color: #f6f1e6;
400
+ padding: 10px;
401
+ text-align: left;
402
+ }
403
+
404
+ .question-bank button.selected {
405
+ border-color: rgba(127, 156, 255, 0.56);
406
+ background: rgba(127, 156, 255, 0.14);
407
+ }
408
+
409
+ .question-bank button.kind-likert {
410
+ border-left: 4px solid rgba(127, 156, 255, 0.8);
411
+ }
412
+
413
+ .question-bank button.kind-open {
414
+ border-left: 4px solid rgba(217, 162, 79, 0.86);
415
+ }
416
+
417
+ .question-bank span,
418
+ .question-bank em {
419
+ color: rgba(246, 241, 230, 0.58);
420
+ font-size: 11px;
421
+ font-style: normal;
422
+ }
423
+
424
+ .question-bank strong {
425
+ line-height: 1.3;
426
+ }
427
+
428
+ .custom-question {
429
+ display: grid;
430
+ grid-template-columns: minmax(0, 1fr) auto;
431
+ gap: 8px;
432
+ margin-top: 14px;
433
+ border-top: 1px solid rgba(246, 241, 230, 0.1);
434
+ padding-top: 12px;
435
+ }
436
+
437
+ .custom-kind {
438
+ grid-column: 1 / -1;
439
+ }
440
+
441
+ .question-bank-toggle {
442
+ display: inline-flex;
443
+ align-items: center;
444
+ gap: 5px;
445
+ margin-left: auto;
446
+ border: 1px solid rgba(84, 96, 137, 0.16);
447
+ border-radius: 999px;
448
+ padding: 7px 10px;
449
+ background: #f5f7fc;
450
+ color: #273047;
451
+ font-size: 12px;
452
+ font-weight: 850;
453
+ }
454
+
455
+ .question-bank-toggle svg {
456
+ transition: transform 0.16s ease;
457
+ }
458
+
459
+ .question-bank-toggle.open svg {
460
+ transform: rotate(180deg);
461
+ }
462
+
463
+ .silicon-results {
464
+ min-width: 0;
465
+ padding: 18px;
466
+ }
467
+
468
+ .silicon-result-head {
469
+ display: flex;
470
+ justify-content: space-between;
471
+ gap: 14px;
472
+ align-items: center;
473
+ padding: 14px 16px;
474
+ }
475
+
476
+ .silicon-result-head h2 {
477
+ margin: 2px 0 0;
478
+ font-size: clamp(28px, 4vw, 58px);
479
+ line-height: 0.96;
480
+ }
481
+
482
+ .result-stats span {
483
+ border: 1px solid rgba(246, 241, 230, 0.12);
484
+ border-radius: 999px;
485
+ padding: 7px 10px;
486
+ background: rgba(246, 241, 230, 0.06);
487
+ color: rgba(246, 241, 230, 0.68);
488
+ font-size: 12px;
489
+ font-weight: 800;
490
+ }
491
+
492
+ .result-stats strong {
493
+ color: #f6f1e6;
494
+ }
495
+
496
+ .silicon-summary-panel,
497
+ .silicon-chart-card,
498
+ .silicon-open-panel {
499
+ padding: 14px;
500
+ }
501
+
502
+ .silicon-open-panel header,
503
+ .silicon-chart-card header {
504
+ display: flex;
505
+ align-items: flex-start;
506
+ justify-content: space-between;
507
+ gap: 12px;
508
+ }
509
+
510
+ .method-note {
511
+ border: 1px solid rgba(246, 241, 230, 0.1);
512
+ border-radius: 999px;
513
+ padding: 7px 9px;
514
+ background: rgba(246, 241, 230, 0.055);
515
+ }
516
+
517
+ .silicon-summary-panel {
518
+ display: grid;
519
+ align-content: start;
520
+ gap: 10px;
521
+ margin-top: 12px;
522
+ }
523
+
524
+ .silicon-metric {
525
+ display: flex;
526
+ align-items: center;
527
+ justify-content: space-between;
528
+ gap: 12px;
529
+ border-bottom: 1px solid rgba(246, 241, 230, 0.09);
530
+ padding: 10px 0;
531
+ }
532
+
533
+ .silicon-metric span {
534
+ color: rgba(246, 241, 230, 0.6);
535
+ font-size: 12px;
536
+ font-weight: 780;
537
+ }
538
+
539
+ .silicon-metric strong {
540
+ font-size: 19px;
541
+ }
542
+
543
+ .method-note {
544
+ display: flex;
545
+ align-items: flex-start;
546
+ gap: 8px;
547
+ border-radius: 13px;
548
+ color: rgba(246, 241, 230, 0.68);
549
+ font-size: 12px;
550
+ line-height: 1.45;
551
+ }
552
+
553
+ .silicon-chart-grid {
554
+ display: grid;
555
+ grid-template-columns: repeat(4, minmax(0, 1fr));
556
+ gap: 12px;
557
+ margin-top: 12px;
558
+ }
559
+
560
+ .silicon-chart-grid.single {
561
+ grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
562
+ }
563
+
564
+ .silicon-chart-card {
565
+ min-width: 0;
566
+ }
567
+
568
+ .silicon-chart-card.wide,
569
+ .silicon-open-panel {
570
+ grid-column: span 2;
571
+ }
572
+
573
+ .silicon-chart {
574
+ height: 260px;
575
+ margin-top: 10px;
576
+ }
577
+
578
+ .silicon-open-panel {
579
+ min-width: 0;
580
+ }
581
+
582
+ .silicon-answer-list {
583
+ display: grid;
584
+ gap: 8px;
585
+ max-height: 324px;
586
+ overflow: auto;
587
+ margin-top: 10px;
588
+ }
589
+
590
+ .silicon-answer-list article {
591
+ display: grid;
592
+ gap: 5px;
593
+ border: 1px solid rgba(246, 241, 230, 0.1);
594
+ border-radius: 12px;
595
+ padding: 10px;
596
+ background: rgba(246, 241, 230, 0.045);
597
+ }
598
+
599
+ .silicon-answer-list span {
600
+ color: rgba(246, 241, 230, 0.55);
601
+ font-size: 11px;
602
+ font-weight: 780;
603
+ }
604
+
605
+ .silicon-answer-list p {
606
+ margin: 0;
607
+ color: rgba(246, 241, 230, 0.84);
608
+ font-size: 13px;
609
+ line-height: 1.45;
610
+ }
611
+
612
+ .silicon-answer-list article strong {
613
+ width: fit-content;
614
+ border-radius: 999px;
615
+ padding: 4px 7px;
616
+ background: rgba(127, 156, 255, 0.16);
617
+ color: #b7c4ff;
618
+ font-size: 11px;
619
+ }
620
+
621
+ .location-tabs,
622
+ .result-mode-buttons,
623
+ .metric-tabs {
624
+ display: flex;
625
+ flex-wrap: wrap;
626
+ gap: 8px;
627
+ margin-top: 12px;
628
+ }
629
+
630
+ .location-tabs button,
631
+ .result-mode-buttons button,
632
+ .metric-tabs button {
633
+ min-height: 34px;
634
+ border: 1px solid rgba(246, 241, 230, 0.12);
635
+ border-radius: 999px;
636
+ padding: 7px 10px;
637
+ background: rgba(246, 241, 230, 0.06);
638
+ color: #f6f1e6;
639
+ font-size: 12px;
640
+ font-weight: 850;
641
+ }
642
+
643
+ .location-tabs button.active,
644
+ .result-mode-buttons button.active,
645
+ .metric-tabs button.active {
646
+ border-color: rgba(127, 156, 255, 0.58);
647
+ background: rgba(127, 156, 255, 0.16);
648
+ }
649
+
650
+ .location-toggle-grid {
651
+ display: grid;
652
+ grid-template-columns: repeat(4, minmax(0, 1fr));
653
+ gap: 8px;
654
+ max-height: 320px;
655
+ overflow: auto;
656
+ margin-top: 10px;
657
+ padding-right: 4px;
658
+ }
659
+
660
+ .location-detail-panel {
661
+ margin-top: 12px;
662
+ border-top: 1px solid rgba(246, 241, 230, 0.09);
663
+ padding-top: 12px;
664
+ }
665
+
666
+ .location-detail-tabs {
667
+ display: flex;
668
+ flex-wrap: wrap;
669
+ gap: 8px;
670
+ }
671
+
672
+ .location-detail-tabs button,
673
+ .nemotron-actions button,
674
+ .nemotron-column-grid button {
675
+ border: 1px solid rgba(246, 241, 230, 0.12);
676
+ border-radius: 999px;
677
+ background: rgba(246, 241, 230, 0.055);
678
+ color: #f6f1e6;
679
+ font-size: 12px;
680
+ font-weight: 820;
681
+ }
682
+
683
+ .location-detail-tabs button {
684
+ min-height: 30px;
685
+ padding: 6px 10px;
686
+ }
687
+
688
+ .location-detail-tabs button.active,
689
+ .nemotron-column-grid button.active {
690
+ border-color: rgba(127, 156, 255, 0.58);
691
+ background: rgba(127, 156, 255, 0.16);
692
+ }
693
+
694
+ .detail-grid {
695
+ max-height: 220px;
696
+ }
697
+
698
+ .selector-empty-note {
699
+ margin: 12px 0 0;
700
+ border: 1px dashed rgba(246, 241, 230, 0.16);
701
+ border-radius: 14px;
702
+ padding: 14px;
703
+ color: #a8a3b8;
704
+ background: rgba(246, 241, 230, 0.035);
705
+ font-size: 12px;
706
+ font-weight: 760;
707
+ }
708
+
709
+ .persona-toggle-grid {
710
+ grid-template-columns: repeat(4, minmax(0, 1fr));
711
+ }
712
+
713
+ .nemotron-actions {
714
+ display: flex;
715
+ flex-wrap: wrap;
716
+ gap: 8px;
717
+ margin-top: 12px;
718
+ }
719
+
720
+ .nemotron-actions button {
721
+ min-height: 32px;
722
+ padding: 6px 10px;
723
+ }
724
+
725
+ .nemotron-column-section {
726
+ margin-top: 12px;
727
+ }
728
+
729
+ .nemotron-column-section > span {
730
+ display: block;
731
+ margin-bottom: 8px;
732
+ color: rgba(246, 241, 230, 0.58);
733
+ font-size: 11px;
734
+ font-weight: 850;
735
+ letter-spacing: 0.07em;
736
+ text-transform: uppercase;
737
+ }
738
+
739
+ .nemotron-column-grid {
740
+ display: flex;
741
+ flex-wrap: wrap;
742
+ gap: 8px;
743
+ }
744
+
745
+ .nemotron-column-grid button {
746
+ min-height: 32px;
747
+ padding: 6px 10px;
748
+ color: rgba(246, 241, 230, 0.74);
749
+ }
750
+
751
+ .silicon-shell.has-results .location-toggle-grid,
752
+ .silicon-shell.has-results .persona-toggle-grid {
753
+ grid-template-columns: repeat(2, minmax(0, 1fr));
754
+ max-height: 240px;
755
+ }
756
+
757
+ .silicon-state-panel {
758
+ display: flex;
759
+ align-items: center;
760
+ gap: 14px;
761
+ margin-top: 12px;
762
+ border: 1px solid rgba(246, 241, 230, 0.12);
763
+ border-radius: 18px;
764
+ padding: 18px;
765
+ background: rgba(246, 241, 230, 0.055);
766
+ }
767
+
768
+ .silicon-state-panel span,
769
+ .explorer-toolbar span,
770
+ .question-tabs span,
771
+ .metric-card-item span {
772
+ display: block;
773
+ color: rgba(246, 241, 230, 0.56);
774
+ font-size: 11px;
775
+ font-weight: 850;
776
+ letter-spacing: 0.07em;
777
+ text-transform: uppercase;
778
+ }
779
+
780
+ .silicon-state-panel strong {
781
+ display: block;
782
+ margin-top: 2px;
783
+ font-size: 18px;
784
+ }
785
+
786
+ .silicon-state-panel p {
787
+ margin: 6px 0 0;
788
+ color: rgba(246, 241, 230, 0.62);
789
+ font-size: 13px;
790
+ line-height: 1.45;
791
+ }
792
+
793
+ .silicon-spinner {
794
+ width: 34px;
795
+ height: 34px;
796
+ flex: 0 0 auto;
797
+ border: 3px solid rgba(246, 241, 230, 0.16);
798
+ border-top-color: #d9a24f;
799
+ border-radius: 999px;
800
+ animation: silicon-spin 0.9s linear infinite;
801
+ }
802
+
803
+ @keyframes silicon-spin {
804
+ to {
805
+ transform: rotate(360deg);
806
+ }
807
+ }
808
+
809
+ .silicon-explorer {
810
+ margin-top: 12px;
811
+ }
812
+
813
+ .explorer-toolbar {
814
+ display: flex;
815
+ justify-content: space-between;
816
+ gap: 12px;
817
+ align-items: flex-start;
818
+ border: 1px solid rgba(246, 241, 230, 0.12);
819
+ border-radius: 18px;
820
+ padding: 14px;
821
+ background: rgba(246, 241, 230, 0.055);
822
+ }
823
+
824
+ .explorer-toolbar strong {
825
+ display: block;
826
+ margin-top: 3px;
827
+ font-size: 17px;
828
+ }
829
+
830
+ .question-tabs {
831
+ display: grid;
832
+ grid-template-columns: repeat(4, minmax(0, 1fr));
833
+ gap: 8px;
834
+ margin-top: 12px;
835
+ }
836
+
837
+ .question-tabs button {
838
+ display: grid;
839
+ gap: 5px;
840
+ min-height: 84px;
841
+ border: 1px solid rgba(246, 241, 230, 0.1);
842
+ border-radius: 14px;
843
+ padding: 10px;
844
+ background: rgba(246, 241, 230, 0.045);
845
+ color: #f6f1e6;
846
+ text-align: left;
847
+ }
848
+
849
+ .question-tabs button.kind-likert {
850
+ border-top: 3px solid rgba(127, 156, 255, 0.82);
851
+ }
852
+
853
+ .question-tabs button.kind-open {
854
+ border-top: 3px solid rgba(217, 162, 79, 0.9);
855
+ }
856
+
857
+ .question-tabs button.active {
858
+ border-color: rgba(246, 241, 230, 0.22);
859
+ background: rgba(246, 241, 230, 0.09);
860
+ }
861
+
862
+ .question-tabs strong {
863
+ line-height: 1.28;
864
+ }
865
+
866
+ .metric-card-grid {
867
+ display: grid;
868
+ grid-template-columns: repeat(2, minmax(0, 1fr));
869
+ gap: 10px;
870
+ margin-top: 12px;
871
+ }
872
+
873
+ .metric-card-item {
874
+ border: 1px solid rgba(246, 241, 230, 0.1);
875
+ border-radius: 14px;
876
+ padding: 12px;
877
+ background: rgba(246, 241, 230, 0.045);
878
+ }
879
+
880
+ .metric-card-item strong {
881
+ display: block;
882
+ margin-top: 8px;
883
+ font-size: 24px;
884
+ }
885
+
886
+ /* Silicon Sampling: light research-dashboard theme */
887
+ .silicon-shell {
888
+ background:
889
+ linear-gradient(180deg, rgba(247, 249, 255, 0.94), rgba(255, 255, 255, 0.98)),
890
+ #f8fafc;
891
+ color: #18213a;
892
+ }
893
+
894
+ .silicon-config {
895
+ border-right-color: rgba(84, 96, 137, 0.16);
896
+ background: rgba(248, 250, 252, 0.86);
897
+ }
898
+
899
+ .silicon-hero,
900
+ .silicon-card,
901
+ .silicon-result-head,
902
+ .silicon-summary-panel,
903
+ .silicon-chart-card,
904
+ .silicon-open-panel,
905
+ .explorer-toolbar,
906
+ .silicon-response-table-card,
907
+ .silicon-state-panel {
908
+ border-color: rgba(84, 96, 137, 0.14);
909
+ background: rgba(255, 255, 255, 0.92);
910
+ box-shadow: 0 18px 48px rgba(39, 55, 96, 0.1);
911
+ backdrop-filter: blur(18px) saturate(1.08);
912
+ }
913
+
914
+ .silicon-brand {
915
+ color: #18213a;
916
+ }
917
+
918
+ .silicon-brand span {
919
+ border-color: rgba(91, 110, 225, 0.24);
920
+ background:
921
+ linear-gradient(90deg, transparent 45%, rgba(255, 255, 255, .82) 46%, rgba(255, 255, 255, .82) 54%, transparent 55%),
922
+ linear-gradient(0deg, transparent 45%, rgba(255, 255, 255, .82) 46%, rgba(255, 255, 255, .82) 54%, transparent 55%),
923
+ linear-gradient(135deg, #5b6ee1, #f08a6c);
924
+ }
925
+
926
+ .silicon-hero p,
927
+ .default-ratio-box span,
928
+ .silicon-run-chip,
929
+ .silicon-metric span,
930
+ .method-note,
931
+ .silicon-state-panel p,
932
+ .estimate-count,
933
+ .question-bank span,
934
+ .question-bank em,
935
+ .number-field span,
936
+ .custom-question label span,
937
+ .nemotron-column-section > span,
938
+ .silicon-section-head span,
939
+ .toggle-head span,
940
+ .silicon-result-head span,
941
+ .silicon-summary-panel header span,
942
+ .silicon-chart-card header span,
943
+ .silicon-open-panel header span,
944
+ .silicon-state-panel span,
945
+ .explorer-toolbar span,
946
+ .question-tabs span,
947
+ .metric-card-item span {
948
+ color: rgba(24, 33, 58, 0.58);
949
+ }
950
+
951
+ .silicon-section-head strong,
952
+ .toggle-head strong,
953
+ .silicon-summary-panel header strong,
954
+ .silicon-chart-card header strong,
955
+ .silicon-open-panel header strong,
956
+ .silicon-state-panel strong,
957
+ .explorer-toolbar strong,
958
+ .question-tabs strong,
959
+ .metric-card-item strong,
960
+ .silicon-metric strong {
961
+ color: #18213a;
962
+ }
963
+
964
+ .silicon-actions button,
965
+ .sample-presets button,
966
+ .custom-kind button,
967
+ .custom-question > button,
968
+ .default-ratio-box button,
969
+ .location-tabs button,
970
+ .result-mode-buttons button,
971
+ .metric-tabs button,
972
+ .location-detail-tabs button,
973
+ .nemotron-actions button,
974
+ .nemotron-column-grid button {
975
+ border-color: rgba(84, 96, 137, 0.16);
976
+ background: #f5f7fc;
977
+ color: #273047;
978
+ }
979
+
980
+ .silicon-actions .primary,
981
+ .sample-presets button.active,
982
+ .custom-kind button.active,
983
+ .location-tabs button.active,
984
+ .result-mode-buttons button.active,
985
+ .metric-tabs button.active,
986
+ .location-detail-tabs button.active,
987
+ .nemotron-column-grid button.active {
988
+ border-color: rgba(91, 110, 225, 0.44);
989
+ background: #eef1ff;
990
+ color: #3647bf;
991
+ }
992
+
993
+ .silicon-actions .primary {
994
+ background: linear-gradient(135deg, #5364d8, #6f7deb);
995
+ color: #fff;
996
+ box-shadow: 0 10px 24px rgba(83, 100, 216, 0.24);
997
+ }
998
+
999
+ .default-ratio-box {
1000
+ border-top-color: rgba(84, 96, 137, 0.12);
1001
+ }
1002
+
1003
+ .default-ratio-box button {
1004
+ border-color: rgba(240, 138, 108, 0.34);
1005
+ background: #fff3ee;
1006
+ color: #af4f37;
1007
+ }
1008
+
1009
+ .silicon-run-chip,
1010
+ .method-note,
1011
+ .selector-empty-note {
1012
+ border-color: rgba(84, 96, 137, 0.13);
1013
+ background: #f7f8fc;
1014
+ }
1015
+
1016
+ .silicon-run-chip.running {
1017
+ border-color: rgba(240, 138, 108, 0.42);
1018
+ background: #fff3ee;
1019
+ color: #9a4a33;
1020
+ }
1021
+
1022
+ .number-field input,
1023
+ .toggle-chip input,
1024
+ .custom-question input,
1025
+ .custom-kind select {
1026
+ border-color: rgba(84, 96, 137, 0.18);
1027
+ background: #fff;
1028
+ color: #18213a;
1029
+ }
1030
+
1031
+ .number-field input:focus,
1032
+ .toggle-chip input:focus,
1033
+ .custom-question input:focus,
1034
+ .custom-kind select:focus {
1035
+ border-color: rgba(91, 110, 225, 0.6);
1036
+ box-shadow: 0 0 0 3px rgba(91, 110, 225, 0.1);
1037
+ }
1038
+
1039
+ .toggle-head em,
1040
+ .result-stats span {
1041
+ border-color: rgba(84, 96, 137, 0.15);
1042
+ background: #f7f8fc;
1043
+ color: rgba(24, 33, 58, 0.62);
1044
+ }
1045
+
1046
+ .result-stats strong {
1047
+ color: #3647bf;
1048
+ }
1049
+
1050
+ .toggle-chip,
1051
+ .question-bank button,
1052
+ .question-tabs button,
1053
+ .metric-card-item,
1054
+ .silicon-answer-list article {
1055
+ border-color: rgba(84, 96, 137, 0.13);
1056
+ background: #fbfcff;
1057
+ }
1058
+
1059
+ .toggle-chip.active,
1060
+ .question-bank button.selected {
1061
+ border-color: rgba(91, 110, 225, 0.38);
1062
+ background: #eef1ff;
1063
+ }
1064
+
1065
+ .toggle-chip button,
1066
+ .question-bank button,
1067
+ .question-tabs button {
1068
+ color: #18213a;
1069
+ }
1070
+
1071
+ .question-bank button.kind-likert,
1072
+ .question-tabs button.kind-likert {
1073
+ border-left-color: #5b6ee1;
1074
+ border-top-color: #5b6ee1;
1075
+ }
1076
+
1077
+ .question-bank button.kind-open,
1078
+ .question-tabs button.kind-open {
1079
+ border-left-color: #f08a6c;
1080
+ border-top-color: #f08a6c;
1081
+ }
1082
+
1083
+ .question-tabs button.active {
1084
+ border-color: rgba(91, 110, 225, 0.38);
1085
+ background: linear-gradient(180deg, #f3f5ff, #ffffff);
1086
+ }
1087
+
1088
+ .silicon-metric {
1089
+ border-bottom-color: rgba(84, 96, 137, 0.12);
1090
+ }
1091
+
1092
+ .silicon-chart-grid.open-only {
1093
+ grid-template-columns: 1fr;
1094
+ }
1095
+
1096
+ .silicon-chart-grid.open-only .silicon-open-panel,
1097
+ .silicon-chart-grid.open-only .silicon-response-table-card,
1098
+ .silicon-response-table-card {
1099
+ grid-column: 1 / -1;
1100
+ }
1101
+
1102
+ .silicon-answer-list span {
1103
+ color: rgba(24, 33, 58, 0.56);
1104
+ }
1105
+
1106
+ .silicon-answer-list p {
1107
+ color: rgba(24, 33, 58, 0.82);
1108
+ }
1109
+
1110
+ .silicon-spinner {
1111
+ border-color: rgba(84, 96, 137, 0.16);
1112
+ border-top-color: #5b6ee1;
1113
+ }
1114
+
1115
+ .custom-question-list {
1116
+ display: grid;
1117
+ gap: 8px;
1118
+ margin-top: 12px;
1119
+ }
1120
+
1121
+ .custom-question-list > span {
1122
+ color: rgba(24, 33, 58, 0.58);
1123
+ font-size: 11px;
1124
+ font-weight: 850;
1125
+ letter-spacing: 0.07em;
1126
+ text-transform: uppercase;
1127
+ }
1128
+
1129
+ .custom-question-list article {
1130
+ display: grid;
1131
+ gap: 4px;
1132
+ border: 1px solid rgba(84, 96, 137, 0.13);
1133
+ border-radius: 12px;
1134
+ padding: 10px;
1135
+ background: #fbfcff;
1136
+ }
1137
+
1138
+ .custom-question-list article.kind-likert {
1139
+ border-left: 4px solid #5b6ee1;
1140
+ }
1141
+
1142
+ .custom-question-list article.kind-open {
1143
+ border-left: 4px solid #f08a6c;
1144
+ }
1145
+
1146
+ .custom-question-list em {
1147
+ color: rgba(24, 33, 58, 0.56);
1148
+ font-size: 11px;
1149
+ font-style: normal;
1150
+ font-weight: 800;
1151
+ }
1152
+
1153
+ .custom-question-list strong {
1154
+ color: #18213a;
1155
+ font-size: 13px;
1156
+ }
1157
+
1158
+ .silicon-response-table-card {
1159
+ min-width: 0;
1160
+ padding: 14px;
1161
+ }
1162
+
1163
+ .silicon-response-table-card header {
1164
+ display: flex;
1165
+ align-items: flex-start;
1166
+ justify-content: space-between;
1167
+ gap: 12px;
1168
+ }
1169
+
1170
+ .silicon-response-table-scroll {
1171
+ max-height: 340px;
1172
+ margin-top: 12px;
1173
+ overflow: auto;
1174
+ border: 1px solid rgba(84, 96, 137, 0.12);
1175
+ border-radius: 14px;
1176
+ }
1177
+
1178
+ .silicon-response-table {
1179
+ width: 100%;
1180
+ min-width: 940px;
1181
+ border-collapse: collapse;
1182
+ background: #fff;
1183
+ font-size: 12px;
1184
+ }
1185
+
1186
+ .silicon-response-table th,
1187
+ .silicon-response-table td {
1188
+ border-bottom: 1px solid rgba(84, 96, 137, 0.1);
1189
+ padding: 10px;
1190
+ text-align: left;
1191
+ vertical-align: top;
1192
+ }
1193
+
1194
+ .silicon-response-table th {
1195
+ position: sticky;
1196
+ top: 0;
1197
+ z-index: 1;
1198
+ background: #f5f7fc;
1199
+ color: rgba(24, 33, 58, 0.64);
1200
+ font-size: 11px;
1201
+ font-weight: 850;
1202
+ }
1203
+
1204
+ .silicon-response-table td {
1205
+ color: rgba(24, 33, 58, 0.8);
1206
+ line-height: 1.4;
1207
+ }
1208
+
1209
+ @media (max-width: 1280px) {
1210
+ .silicon-shell {
1211
+ grid-template-columns: 380px minmax(0, 1fr);
1212
+ }
1213
+
1214
+ .silicon-shell.setup-only .silicon-setup-grid {
1215
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1216
+ }
1217
+
1218
+ .silicon-shell.setup-only .location-card,
1219
+ .silicon-shell.setup-only .persona-card,
1220
+ .silicon-shell.setup-only .nemotron-card,
1221
+ .silicon-shell.setup-only .question-card {
1222
+ grid-column: span 2;
1223
+ }
1224
+
1225
+ .silicon-chart-grid {
1226
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1227
+ }
1228
+
1229
+ .question-tabs {
1230
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1231
+ }
1232
+ }
1233
+
1234
+ @media (max-width: 980px) {
1235
+ .silicon-shell {
1236
+ display: block;
1237
+ }
1238
+
1239
+ .silicon-shell.setup-only .silicon-setup-grid {
1240
+ grid-template-columns: 1fr;
1241
+ }
1242
+
1243
+ .silicon-shell.setup-only .location-card,
1244
+ .silicon-shell.setup-only .persona-card,
1245
+ .silicon-shell.setup-only .nemotron-card,
1246
+ .silicon-shell.setup-only .question-card {
1247
+ grid-column: auto;
1248
+ }
1249
+
1250
+ .silicon-config {
1251
+ position: relative;
1252
+ height: auto;
1253
+ border-right: 0;
1254
+ border-bottom: 1px solid rgba(246, 241, 230, 0.11);
1255
+ }
1256
+
1257
+ .silicon-chart-grid.single,
1258
+ .explorer-toolbar {
1259
+ grid-template-columns: 1fr;
1260
+ }
1261
+
1262
+ .silicon-chart-grid.single,
1263
+ .explorer-toolbar {
1264
+ display: grid;
1265
+ }
1266
+ }
1267
+
1268
+ @media (max-width: 640px) {
1269
+ .silicon-config,
1270
+ .silicon-results {
1271
+ padding: 10px;
1272
+ }
1273
+
1274
+ .silicon-result-head,
1275
+ .silicon-open-panel header,
1276
+ .silicon-chart-card header {
1277
+ display: grid;
1278
+ }
1279
+
1280
+ .toggle-grid,
1281
+ .toggle-grid.compact,
1282
+ .silicon-chart-grid,
1283
+ .silicon-chart-grid.single,
1284
+ .location-toggle-grid,
1285
+ .question-tabs,
1286
+ .metric-card-grid {
1287
+ grid-template-columns: 1fr;
1288
+ }
1289
+
1290
+ .silicon-chart-card.wide,
1291
+ .silicon-open-panel {
1292
+ grid-column: auto;
1293
+ }
1294
+
1295
+ .custom-question {
1296
+ grid-template-columns: 1fr;
1297
+ }
1298
+ }
frontend/src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Silicon Sampling Lab</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/frontend/src/main.tsx"></script>
11
+ </body>
12
+ </html>
package-lock.json ADDED
@@ -0,0 +1,1034 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "silicon-sampling-lab",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "silicon-sampling-lab",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "echarts": "^6.1.0",
12
+ "lucide-react": "^1.22.0",
13
+ "react": "^19.2.7",
14
+ "react-dom": "^19.2.7"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^19.2.17",
18
+ "@types/react-dom": "^19.2.3",
19
+ "@vitejs/plugin-react": "^6.0.3",
20
+ "typescript": "^6.0.3",
21
+ "vite": "^8.1.0"
22
+ }
23
+ },
24
+ "node_modules/@emnapi/core": {
25
+ "version": "1.11.1",
26
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
27
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
28
+ "dev": true,
29
+ "license": "MIT",
30
+ "optional": true,
31
+ "dependencies": {
32
+ "@emnapi/wasi-threads": "1.2.2",
33
+ "tslib": "^2.4.0"
34
+ }
35
+ },
36
+ "node_modules/@emnapi/core/node_modules/tslib": {
37
+ "version": "2.8.1",
38
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
39
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
40
+ "dev": true,
41
+ "license": "0BSD",
42
+ "optional": true
43
+ },
44
+ "node_modules/@emnapi/runtime": {
45
+ "version": "1.11.1",
46
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
47
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
48
+ "dev": true,
49
+ "license": "MIT",
50
+ "optional": true,
51
+ "dependencies": {
52
+ "tslib": "^2.4.0"
53
+ }
54
+ },
55
+ "node_modules/@emnapi/runtime/node_modules/tslib": {
56
+ "version": "2.8.1",
57
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
58
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
59
+ "dev": true,
60
+ "license": "0BSD",
61
+ "optional": true
62
+ },
63
+ "node_modules/@emnapi/wasi-threads": {
64
+ "version": "1.2.2",
65
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
66
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
67
+ "dev": true,
68
+ "license": "MIT",
69
+ "optional": true,
70
+ "dependencies": {
71
+ "tslib": "^2.4.0"
72
+ }
73
+ },
74
+ "node_modules/@emnapi/wasi-threads/node_modules/tslib": {
75
+ "version": "2.8.1",
76
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
77
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
78
+ "dev": true,
79
+ "license": "0BSD",
80
+ "optional": true
81
+ },
82
+ "node_modules/@napi-rs/wasm-runtime": {
83
+ "version": "1.1.6",
84
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
85
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
86
+ "dev": true,
87
+ "license": "MIT",
88
+ "optional": true,
89
+ "dependencies": {
90
+ "@tybys/wasm-util": "^0.10.3"
91
+ },
92
+ "funding": {
93
+ "type": "github",
94
+ "url": "https://github.com/sponsors/Brooooooklyn"
95
+ },
96
+ "peerDependencies": {
97
+ "@emnapi/core": "^1.7.1",
98
+ "@emnapi/runtime": "^1.7.1"
99
+ }
100
+ },
101
+ "node_modules/@oxc-project/types": {
102
+ "version": "0.137.0",
103
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
104
+ "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
105
+ "dev": true,
106
+ "license": "MIT",
107
+ "funding": {
108
+ "url": "https://github.com/sponsors/Boshen"
109
+ }
110
+ },
111
+ "node_modules/@rolldown/binding-android-arm64": {
112
+ "version": "1.1.3",
113
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
114
+ "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
115
+ "cpu": [
116
+ "arm64"
117
+ ],
118
+ "dev": true,
119
+ "license": "MIT",
120
+ "optional": true,
121
+ "os": [
122
+ "android"
123
+ ],
124
+ "engines": {
125
+ "node": "^20.19.0 || >=22.12.0"
126
+ }
127
+ },
128
+ "node_modules/@rolldown/binding-darwin-arm64": {
129
+ "version": "1.1.3",
130
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
131
+ "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
132
+ "cpu": [
133
+ "arm64"
134
+ ],
135
+ "dev": true,
136
+ "license": "MIT",
137
+ "optional": true,
138
+ "os": [
139
+ "darwin"
140
+ ],
141
+ "engines": {
142
+ "node": "^20.19.0 || >=22.12.0"
143
+ }
144
+ },
145
+ "node_modules/@rolldown/binding-darwin-x64": {
146
+ "version": "1.1.3",
147
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
148
+ "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
149
+ "cpu": [
150
+ "x64"
151
+ ],
152
+ "dev": true,
153
+ "license": "MIT",
154
+ "optional": true,
155
+ "os": [
156
+ "darwin"
157
+ ],
158
+ "engines": {
159
+ "node": "^20.19.0 || >=22.12.0"
160
+ }
161
+ },
162
+ "node_modules/@rolldown/binding-freebsd-x64": {
163
+ "version": "1.1.3",
164
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
165
+ "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
166
+ "cpu": [
167
+ "x64"
168
+ ],
169
+ "dev": true,
170
+ "license": "MIT",
171
+ "optional": true,
172
+ "os": [
173
+ "freebsd"
174
+ ],
175
+ "engines": {
176
+ "node": "^20.19.0 || >=22.12.0"
177
+ }
178
+ },
179
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
180
+ "version": "1.1.3",
181
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
182
+ "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
183
+ "cpu": [
184
+ "arm"
185
+ ],
186
+ "dev": true,
187
+ "license": "MIT",
188
+ "optional": true,
189
+ "os": [
190
+ "linux"
191
+ ],
192
+ "engines": {
193
+ "node": "^20.19.0 || >=22.12.0"
194
+ }
195
+ },
196
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
197
+ "version": "1.1.3",
198
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
199
+ "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
200
+ "cpu": [
201
+ "arm64"
202
+ ],
203
+ "dev": true,
204
+ "license": "MIT",
205
+ "optional": true,
206
+ "os": [
207
+ "linux"
208
+ ],
209
+ "engines": {
210
+ "node": "^20.19.0 || >=22.12.0"
211
+ }
212
+ },
213
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
214
+ "version": "1.1.3",
215
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
216
+ "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
217
+ "cpu": [
218
+ "arm64"
219
+ ],
220
+ "dev": true,
221
+ "license": "MIT",
222
+ "optional": true,
223
+ "os": [
224
+ "linux"
225
+ ],
226
+ "engines": {
227
+ "node": "^20.19.0 || >=22.12.0"
228
+ }
229
+ },
230
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
231
+ "version": "1.1.3",
232
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
233
+ "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
234
+ "cpu": [
235
+ "ppc64"
236
+ ],
237
+ "dev": true,
238
+ "license": "MIT",
239
+ "optional": true,
240
+ "os": [
241
+ "linux"
242
+ ],
243
+ "engines": {
244
+ "node": "^20.19.0 || >=22.12.0"
245
+ }
246
+ },
247
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
248
+ "version": "1.1.3",
249
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
250
+ "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
251
+ "cpu": [
252
+ "s390x"
253
+ ],
254
+ "dev": true,
255
+ "license": "MIT",
256
+ "optional": true,
257
+ "os": [
258
+ "linux"
259
+ ],
260
+ "engines": {
261
+ "node": "^20.19.0 || >=22.12.0"
262
+ }
263
+ },
264
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
265
+ "version": "1.1.3",
266
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
267
+ "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
268
+ "cpu": [
269
+ "x64"
270
+ ],
271
+ "dev": true,
272
+ "license": "MIT",
273
+ "optional": true,
274
+ "os": [
275
+ "linux"
276
+ ],
277
+ "engines": {
278
+ "node": "^20.19.0 || >=22.12.0"
279
+ }
280
+ },
281
+ "node_modules/@rolldown/binding-linux-x64-musl": {
282
+ "version": "1.1.3",
283
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
284
+ "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
285
+ "cpu": [
286
+ "x64"
287
+ ],
288
+ "dev": true,
289
+ "license": "MIT",
290
+ "optional": true,
291
+ "os": [
292
+ "linux"
293
+ ],
294
+ "engines": {
295
+ "node": "^20.19.0 || >=22.12.0"
296
+ }
297
+ },
298
+ "node_modules/@rolldown/binding-openharmony-arm64": {
299
+ "version": "1.1.3",
300
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
301
+ "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
302
+ "cpu": [
303
+ "arm64"
304
+ ],
305
+ "dev": true,
306
+ "license": "MIT",
307
+ "optional": true,
308
+ "os": [
309
+ "openharmony"
310
+ ],
311
+ "engines": {
312
+ "node": "^20.19.0 || >=22.12.0"
313
+ }
314
+ },
315
+ "node_modules/@rolldown/binding-wasm32-wasi": {
316
+ "version": "1.1.3",
317
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
318
+ "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
319
+ "cpu": [
320
+ "wasm32"
321
+ ],
322
+ "dev": true,
323
+ "license": "MIT",
324
+ "optional": true,
325
+ "dependencies": {
326
+ "@emnapi/core": "1.11.1",
327
+ "@emnapi/runtime": "1.11.1",
328
+ "@napi-rs/wasm-runtime": "^1.1.6"
329
+ },
330
+ "engines": {
331
+ "node": "^20.19.0 || >=22.12.0"
332
+ }
333
+ },
334
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
335
+ "version": "1.1.3",
336
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
337
+ "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
338
+ "cpu": [
339
+ "arm64"
340
+ ],
341
+ "dev": true,
342
+ "license": "MIT",
343
+ "optional": true,
344
+ "os": [
345
+ "win32"
346
+ ],
347
+ "engines": {
348
+ "node": "^20.19.0 || >=22.12.0"
349
+ }
350
+ },
351
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
352
+ "version": "1.1.3",
353
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
354
+ "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
355
+ "cpu": [
356
+ "x64"
357
+ ],
358
+ "dev": true,
359
+ "license": "MIT",
360
+ "optional": true,
361
+ "os": [
362
+ "win32"
363
+ ],
364
+ "engines": {
365
+ "node": "^20.19.0 || >=22.12.0"
366
+ }
367
+ },
368
+ "node_modules/@rolldown/pluginutils": {
369
+ "version": "1.0.1",
370
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
371
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
372
+ "dev": true,
373
+ "license": "MIT"
374
+ },
375
+ "node_modules/@tybys/wasm-util": {
376
+ "version": "0.10.3",
377
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
378
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
379
+ "dev": true,
380
+ "license": "MIT",
381
+ "optional": true,
382
+ "dependencies": {
383
+ "tslib": "^2.4.0"
384
+ }
385
+ },
386
+ "node_modules/@tybys/wasm-util/node_modules/tslib": {
387
+ "version": "2.8.1",
388
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
389
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
390
+ "dev": true,
391
+ "license": "0BSD",
392
+ "optional": true
393
+ },
394
+ "node_modules/@types/react": {
395
+ "version": "19.2.17",
396
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
397
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
398
+ "dev": true,
399
+ "license": "MIT",
400
+ "dependencies": {
401
+ "csstype": "^3.2.2"
402
+ }
403
+ },
404
+ "node_modules/@types/react-dom": {
405
+ "version": "19.2.3",
406
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
407
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
408
+ "dev": true,
409
+ "license": "MIT",
410
+ "peerDependencies": {
411
+ "@types/react": "^19.2.0"
412
+ }
413
+ },
414
+ "node_modules/@vitejs/plugin-react": {
415
+ "version": "6.0.3",
416
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
417
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
418
+ "dev": true,
419
+ "license": "MIT",
420
+ "dependencies": {
421
+ "@rolldown/pluginutils": "^1.0.1"
422
+ },
423
+ "engines": {
424
+ "node": "^20.19.0 || >=22.12.0"
425
+ },
426
+ "peerDependencies": {
427
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
428
+ "babel-plugin-react-compiler": "^1.0.0",
429
+ "vite": "^8.0.0"
430
+ },
431
+ "peerDependenciesMeta": {
432
+ "@rolldown/plugin-babel": {
433
+ "optional": true
434
+ },
435
+ "babel-plugin-react-compiler": {
436
+ "optional": true
437
+ }
438
+ }
439
+ },
440
+ "node_modules/csstype": {
441
+ "version": "3.2.3",
442
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
443
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
444
+ "dev": true,
445
+ "license": "MIT"
446
+ },
447
+ "node_modules/detect-libc": {
448
+ "version": "2.1.2",
449
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
450
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
451
+ "dev": true,
452
+ "license": "Apache-2.0",
453
+ "engines": {
454
+ "node": ">=8"
455
+ }
456
+ },
457
+ "node_modules/echarts": {
458
+ "version": "6.1.0",
459
+ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
460
+ "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
461
+ "license": "Apache-2.0",
462
+ "dependencies": {
463
+ "tslib": "2.3.0",
464
+ "zrender": "6.1.0"
465
+ }
466
+ },
467
+ "node_modules/fdir": {
468
+ "version": "6.5.0",
469
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
470
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
471
+ "dev": true,
472
+ "license": "MIT",
473
+ "engines": {
474
+ "node": ">=12.0.0"
475
+ },
476
+ "peerDependencies": {
477
+ "picomatch": "^3 || ^4"
478
+ },
479
+ "peerDependenciesMeta": {
480
+ "picomatch": {
481
+ "optional": true
482
+ }
483
+ }
484
+ },
485
+ "node_modules/fsevents": {
486
+ "version": "2.3.3",
487
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
488
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
489
+ "dev": true,
490
+ "hasInstallScript": true,
491
+ "license": "MIT",
492
+ "optional": true,
493
+ "os": [
494
+ "darwin"
495
+ ],
496
+ "engines": {
497
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
498
+ }
499
+ },
500
+ "node_modules/lightningcss": {
501
+ "version": "1.32.0",
502
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
503
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
504
+ "dev": true,
505
+ "license": "MPL-2.0",
506
+ "dependencies": {
507
+ "detect-libc": "^2.0.3"
508
+ },
509
+ "engines": {
510
+ "node": ">= 12.0.0"
511
+ },
512
+ "funding": {
513
+ "type": "opencollective",
514
+ "url": "https://opencollective.com/parcel"
515
+ },
516
+ "optionalDependencies": {
517
+ "lightningcss-android-arm64": "1.32.0",
518
+ "lightningcss-darwin-arm64": "1.32.0",
519
+ "lightningcss-darwin-x64": "1.32.0",
520
+ "lightningcss-freebsd-x64": "1.32.0",
521
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
522
+ "lightningcss-linux-arm64-gnu": "1.32.0",
523
+ "lightningcss-linux-arm64-musl": "1.32.0",
524
+ "lightningcss-linux-x64-gnu": "1.32.0",
525
+ "lightningcss-linux-x64-musl": "1.32.0",
526
+ "lightningcss-win32-arm64-msvc": "1.32.0",
527
+ "lightningcss-win32-x64-msvc": "1.32.0"
528
+ }
529
+ },
530
+ "node_modules/lightningcss-android-arm64": {
531
+ "version": "1.32.0",
532
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
533
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
534
+ "cpu": [
535
+ "arm64"
536
+ ],
537
+ "dev": true,
538
+ "license": "MPL-2.0",
539
+ "optional": true,
540
+ "os": [
541
+ "android"
542
+ ],
543
+ "engines": {
544
+ "node": ">= 12.0.0"
545
+ },
546
+ "funding": {
547
+ "type": "opencollective",
548
+ "url": "https://opencollective.com/parcel"
549
+ }
550
+ },
551
+ "node_modules/lightningcss-darwin-arm64": {
552
+ "version": "1.32.0",
553
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
554
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
555
+ "cpu": [
556
+ "arm64"
557
+ ],
558
+ "dev": true,
559
+ "license": "MPL-2.0",
560
+ "optional": true,
561
+ "os": [
562
+ "darwin"
563
+ ],
564
+ "engines": {
565
+ "node": ">= 12.0.0"
566
+ },
567
+ "funding": {
568
+ "type": "opencollective",
569
+ "url": "https://opencollective.com/parcel"
570
+ }
571
+ },
572
+ "node_modules/lightningcss-darwin-x64": {
573
+ "version": "1.32.0",
574
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
575
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
576
+ "cpu": [
577
+ "x64"
578
+ ],
579
+ "dev": true,
580
+ "license": "MPL-2.0",
581
+ "optional": true,
582
+ "os": [
583
+ "darwin"
584
+ ],
585
+ "engines": {
586
+ "node": ">= 12.0.0"
587
+ },
588
+ "funding": {
589
+ "type": "opencollective",
590
+ "url": "https://opencollective.com/parcel"
591
+ }
592
+ },
593
+ "node_modules/lightningcss-freebsd-x64": {
594
+ "version": "1.32.0",
595
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
596
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
597
+ "cpu": [
598
+ "x64"
599
+ ],
600
+ "dev": true,
601
+ "license": "MPL-2.0",
602
+ "optional": true,
603
+ "os": [
604
+ "freebsd"
605
+ ],
606
+ "engines": {
607
+ "node": ">= 12.0.0"
608
+ },
609
+ "funding": {
610
+ "type": "opencollective",
611
+ "url": "https://opencollective.com/parcel"
612
+ }
613
+ },
614
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
615
+ "version": "1.32.0",
616
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
617
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
618
+ "cpu": [
619
+ "arm"
620
+ ],
621
+ "dev": true,
622
+ "license": "MPL-2.0",
623
+ "optional": true,
624
+ "os": [
625
+ "linux"
626
+ ],
627
+ "engines": {
628
+ "node": ">= 12.0.0"
629
+ },
630
+ "funding": {
631
+ "type": "opencollective",
632
+ "url": "https://opencollective.com/parcel"
633
+ }
634
+ },
635
+ "node_modules/lightningcss-linux-arm64-gnu": {
636
+ "version": "1.32.0",
637
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
638
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
639
+ "cpu": [
640
+ "arm64"
641
+ ],
642
+ "dev": true,
643
+ "license": "MPL-2.0",
644
+ "optional": true,
645
+ "os": [
646
+ "linux"
647
+ ],
648
+ "engines": {
649
+ "node": ">= 12.0.0"
650
+ },
651
+ "funding": {
652
+ "type": "opencollective",
653
+ "url": "https://opencollective.com/parcel"
654
+ }
655
+ },
656
+ "node_modules/lightningcss-linux-arm64-musl": {
657
+ "version": "1.32.0",
658
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
659
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
660
+ "cpu": [
661
+ "arm64"
662
+ ],
663
+ "dev": true,
664
+ "license": "MPL-2.0",
665
+ "optional": true,
666
+ "os": [
667
+ "linux"
668
+ ],
669
+ "engines": {
670
+ "node": ">= 12.0.0"
671
+ },
672
+ "funding": {
673
+ "type": "opencollective",
674
+ "url": "https://opencollective.com/parcel"
675
+ }
676
+ },
677
+ "node_modules/lightningcss-linux-x64-gnu": {
678
+ "version": "1.32.0",
679
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
680
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
681
+ "cpu": [
682
+ "x64"
683
+ ],
684
+ "dev": true,
685
+ "license": "MPL-2.0",
686
+ "optional": true,
687
+ "os": [
688
+ "linux"
689
+ ],
690
+ "engines": {
691
+ "node": ">= 12.0.0"
692
+ },
693
+ "funding": {
694
+ "type": "opencollective",
695
+ "url": "https://opencollective.com/parcel"
696
+ }
697
+ },
698
+ "node_modules/lightningcss-linux-x64-musl": {
699
+ "version": "1.32.0",
700
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
701
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
702
+ "cpu": [
703
+ "x64"
704
+ ],
705
+ "dev": true,
706
+ "license": "MPL-2.0",
707
+ "optional": true,
708
+ "os": [
709
+ "linux"
710
+ ],
711
+ "engines": {
712
+ "node": ">= 12.0.0"
713
+ },
714
+ "funding": {
715
+ "type": "opencollective",
716
+ "url": "https://opencollective.com/parcel"
717
+ }
718
+ },
719
+ "node_modules/lightningcss-win32-arm64-msvc": {
720
+ "version": "1.32.0",
721
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
722
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
723
+ "cpu": [
724
+ "arm64"
725
+ ],
726
+ "dev": true,
727
+ "license": "MPL-2.0",
728
+ "optional": true,
729
+ "os": [
730
+ "win32"
731
+ ],
732
+ "engines": {
733
+ "node": ">= 12.0.0"
734
+ },
735
+ "funding": {
736
+ "type": "opencollective",
737
+ "url": "https://opencollective.com/parcel"
738
+ }
739
+ },
740
+ "node_modules/lightningcss-win32-x64-msvc": {
741
+ "version": "1.32.0",
742
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
743
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
744
+ "cpu": [
745
+ "x64"
746
+ ],
747
+ "dev": true,
748
+ "license": "MPL-2.0",
749
+ "optional": true,
750
+ "os": [
751
+ "win32"
752
+ ],
753
+ "engines": {
754
+ "node": ">= 12.0.0"
755
+ },
756
+ "funding": {
757
+ "type": "opencollective",
758
+ "url": "https://opencollective.com/parcel"
759
+ }
760
+ },
761
+ "node_modules/lucide-react": {
762
+ "version": "1.22.0",
763
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz",
764
+ "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==",
765
+ "license": "ISC",
766
+ "peerDependencies": {
767
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
768
+ }
769
+ },
770
+ "node_modules/nanoid": {
771
+ "version": "3.3.15",
772
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
773
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
774
+ "dev": true,
775
+ "funding": [
776
+ {
777
+ "type": "github",
778
+ "url": "https://github.com/sponsors/ai"
779
+ }
780
+ ],
781
+ "license": "MIT",
782
+ "bin": {
783
+ "nanoid": "bin/nanoid.cjs"
784
+ },
785
+ "engines": {
786
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
787
+ }
788
+ },
789
+ "node_modules/picocolors": {
790
+ "version": "1.1.1",
791
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
792
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
793
+ "dev": true,
794
+ "license": "ISC"
795
+ },
796
+ "node_modules/picomatch": {
797
+ "version": "4.0.4",
798
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
799
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
800
+ "dev": true,
801
+ "license": "MIT",
802
+ "engines": {
803
+ "node": ">=12"
804
+ },
805
+ "funding": {
806
+ "url": "https://github.com/sponsors/jonschlinkert"
807
+ }
808
+ },
809
+ "node_modules/postcss": {
810
+ "version": "8.5.16",
811
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
812
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
813
+ "dev": true,
814
+ "funding": [
815
+ {
816
+ "type": "opencollective",
817
+ "url": "https://opencollective.com/postcss/"
818
+ },
819
+ {
820
+ "type": "tidelift",
821
+ "url": "https://tidelift.com/funding/github/npm/postcss"
822
+ },
823
+ {
824
+ "type": "github",
825
+ "url": "https://github.com/sponsors/ai"
826
+ }
827
+ ],
828
+ "license": "MIT",
829
+ "dependencies": {
830
+ "nanoid": "^3.3.12",
831
+ "picocolors": "^1.1.1",
832
+ "source-map-js": "^1.2.1"
833
+ },
834
+ "engines": {
835
+ "node": "^10 || ^12 || >=14"
836
+ }
837
+ },
838
+ "node_modules/react": {
839
+ "version": "19.2.7",
840
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
841
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
842
+ "license": "MIT",
843
+ "engines": {
844
+ "node": ">=0.10.0"
845
+ }
846
+ },
847
+ "node_modules/react-dom": {
848
+ "version": "19.2.7",
849
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
850
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
851
+ "license": "MIT",
852
+ "dependencies": {
853
+ "scheduler": "^0.27.0"
854
+ },
855
+ "peerDependencies": {
856
+ "react": "^19.2.7"
857
+ }
858
+ },
859
+ "node_modules/rolldown": {
860
+ "version": "1.1.3",
861
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
862
+ "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
863
+ "dev": true,
864
+ "license": "MIT",
865
+ "dependencies": {
866
+ "@oxc-project/types": "=0.137.0",
867
+ "@rolldown/pluginutils": "^1.0.0"
868
+ },
869
+ "bin": {
870
+ "rolldown": "bin/cli.mjs"
871
+ },
872
+ "engines": {
873
+ "node": "^20.19.0 || >=22.12.0"
874
+ },
875
+ "optionalDependencies": {
876
+ "@rolldown/binding-android-arm64": "1.1.3",
877
+ "@rolldown/binding-darwin-arm64": "1.1.3",
878
+ "@rolldown/binding-darwin-x64": "1.1.3",
879
+ "@rolldown/binding-freebsd-x64": "1.1.3",
880
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
881
+ "@rolldown/binding-linux-arm64-gnu": "1.1.3",
882
+ "@rolldown/binding-linux-arm64-musl": "1.1.3",
883
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
884
+ "@rolldown/binding-linux-s390x-gnu": "1.1.3",
885
+ "@rolldown/binding-linux-x64-gnu": "1.1.3",
886
+ "@rolldown/binding-linux-x64-musl": "1.1.3",
887
+ "@rolldown/binding-openharmony-arm64": "1.1.3",
888
+ "@rolldown/binding-wasm32-wasi": "1.1.3",
889
+ "@rolldown/binding-win32-arm64-msvc": "1.1.3",
890
+ "@rolldown/binding-win32-x64-msvc": "1.1.3"
891
+ }
892
+ },
893
+ "node_modules/scheduler": {
894
+ "version": "0.27.0",
895
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
896
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
897
+ "license": "MIT"
898
+ },
899
+ "node_modules/source-map-js": {
900
+ "version": "1.2.1",
901
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
902
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
903
+ "dev": true,
904
+ "license": "BSD-3-Clause",
905
+ "engines": {
906
+ "node": ">=0.10.0"
907
+ }
908
+ },
909
+ "node_modules/tinyglobby": {
910
+ "version": "0.2.17",
911
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
912
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
913
+ "dev": true,
914
+ "license": "MIT",
915
+ "dependencies": {
916
+ "fdir": "^6.5.0",
917
+ "picomatch": "^4.0.4"
918
+ },
919
+ "engines": {
920
+ "node": ">=12.0.0"
921
+ },
922
+ "funding": {
923
+ "url": "https://github.com/sponsors/SuperchupuDev"
924
+ }
925
+ },
926
+ "node_modules/tslib": {
927
+ "version": "2.3.0",
928
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
929
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
930
+ "license": "0BSD"
931
+ },
932
+ "node_modules/typescript": {
933
+ "version": "6.0.3",
934
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
935
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
936
+ "dev": true,
937
+ "license": "Apache-2.0",
938
+ "bin": {
939
+ "tsc": "bin/tsc",
940
+ "tsserver": "bin/tsserver"
941
+ },
942
+ "engines": {
943
+ "node": ">=14.17"
944
+ }
945
+ },
946
+ "node_modules/vite": {
947
+ "version": "8.1.0",
948
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
949
+ "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
950
+ "dev": true,
951
+ "license": "MIT",
952
+ "dependencies": {
953
+ "lightningcss": "^1.32.0",
954
+ "picomatch": "^4.0.4",
955
+ "postcss": "^8.5.15",
956
+ "rolldown": "~1.1.2",
957
+ "tinyglobby": "^0.2.17"
958
+ },
959
+ "bin": {
960
+ "vite": "bin/vite.js"
961
+ },
962
+ "engines": {
963
+ "node": "^20.19.0 || >=22.12.0"
964
+ },
965
+ "funding": {
966
+ "url": "https://github.com/vitejs/vite?sponsor=1"
967
+ },
968
+ "optionalDependencies": {
969
+ "fsevents": "~2.3.3"
970
+ },
971
+ "peerDependencies": {
972
+ "@types/node": "^20.19.0 || >=22.12.0",
973
+ "@vitejs/devtools": "^0.3.0",
974
+ "esbuild": "^0.27.0 || ^0.28.0",
975
+ "jiti": ">=1.21.0",
976
+ "less": "^4.0.0",
977
+ "sass": "^1.70.0",
978
+ "sass-embedded": "^1.70.0",
979
+ "stylus": ">=0.54.8",
980
+ "sugarss": "^5.0.0",
981
+ "terser": "^5.16.0",
982
+ "tsx": "^4.8.1",
983
+ "yaml": "^2.4.2"
984
+ },
985
+ "peerDependenciesMeta": {
986
+ "@types/node": {
987
+ "optional": true
988
+ },
989
+ "@vitejs/devtools": {
990
+ "optional": true
991
+ },
992
+ "esbuild": {
993
+ "optional": true
994
+ },
995
+ "jiti": {
996
+ "optional": true
997
+ },
998
+ "less": {
999
+ "optional": true
1000
+ },
1001
+ "sass": {
1002
+ "optional": true
1003
+ },
1004
+ "sass-embedded": {
1005
+ "optional": true
1006
+ },
1007
+ "stylus": {
1008
+ "optional": true
1009
+ },
1010
+ "sugarss": {
1011
+ "optional": true
1012
+ },
1013
+ "terser": {
1014
+ "optional": true
1015
+ },
1016
+ "tsx": {
1017
+ "optional": true
1018
+ },
1019
+ "yaml": {
1020
+ "optional": true
1021
+ }
1022
+ }
1023
+ },
1024
+ "node_modules/zrender": {
1025
+ "version": "6.1.0",
1026
+ "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
1027
+ "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
1028
+ "license": "BSD-3-Clause",
1029
+ "dependencies": {
1030
+ "tslib": "2.3.0"
1031
+ }
1032
+ }
1033
+ }
1034
+ }
package.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "silicon-sampling-lab",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host 127.0.0.1 --port 5190",
8
+ "build": "tsc -p tsconfig.json && vite build",
9
+ "preview": "vite preview --host 127.0.0.1 --port 5191",
10
+ "start": "python3 server.py --host 0.0.0.0 --port ${PORT:-8765}"
11
+ },
12
+ "dependencies": {
13
+ "echarts": "^6.1.0",
14
+ "lucide-react": "^1.22.0",
15
+ "react": "^19.2.7",
16
+ "react-dom": "^19.2.7"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "^19.2.17",
20
+ "@types/react-dom": "^19.2.3",
21
+ "@vitejs/plugin-react": "^6.0.3",
22
+ "typescript": "^6.0.3",
23
+ "vite": "^8.1.0"
24
+ }
25
+ }
persona_dataset.py ADDED
@@ -0,0 +1,838 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local Nemotron-Personas-Korea dataset support for the simulation UI.
2
+
3
+ The dataset is intentionally treated as a local asset after download. Runtime
4
+ sampling reads downloaded parquet shards through DuckDB; it does not call the
5
+ Hugging Face dataset viewer for rows.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import os
13
+ import re
14
+ import time
15
+ import urllib.request
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ APP_DIR = Path(__file__).resolve().parent
21
+ DATASET_ID = "nvidia/Nemotron-Personas-Korea"
22
+ DATASET_CONFIG = "default"
23
+ DATASET_SPLIT = "train"
24
+ DATA_DIR = Path(os.getenv("NEMOTRON_KOREA_DATA_DIR", APP_DIR / "data" / "nemotron_personas_korea")).expanduser()
25
+ PARQUET_DIR = DATA_DIR / "parquet"
26
+ MANIFEST_PATH = DATA_DIR / "manifest.json"
27
+ DUCKDB_PATH = DATA_DIR / "personas.duckdb"
28
+
29
+ DATASET_COLUMNS = [
30
+ "uuid",
31
+ "professional_persona",
32
+ "sports_persona",
33
+ "arts_persona",
34
+ "travel_persona",
35
+ "culinary_persona",
36
+ "family_persona",
37
+ "persona",
38
+ "cultural_background",
39
+ "skills_and_expertise",
40
+ "skills_and_expertise_list",
41
+ "hobbies_and_interests",
42
+ "hobbies_and_interests_list",
43
+ "career_goals_and_ambitions",
44
+ "sex",
45
+ "age",
46
+ "marital_status",
47
+ "military_status",
48
+ "family_type",
49
+ "housing_type",
50
+ "education_level",
51
+ "bachelors_field",
52
+ "occupation",
53
+ "district",
54
+ "province",
55
+ "country",
56
+ ]
57
+
58
+ TEXT_FIELDS = [
59
+ "persona",
60
+ "professional_persona",
61
+ "family_persona",
62
+ "cultural_background",
63
+ "skills_and_expertise",
64
+ "hobbies_and_interests",
65
+ "career_goals_and_ambitions",
66
+ "sports_persona",
67
+ "arts_persona",
68
+ "travel_persona",
69
+ "culinary_persona",
70
+ ]
71
+
72
+ DEFAULT_SELECTED_FIELDS = [
73
+ "persona",
74
+ "professional_persona",
75
+ "family_persona",
76
+ "hobbies_and_interests",
77
+ "skills_and_expertise",
78
+ "career_goals_and_ambitions",
79
+ ]
80
+
81
+ SEX_ALIASES = {
82
+ "여성": "여자",
83
+ "여": "여자",
84
+ "여자": "여자",
85
+ "female": "여자",
86
+ "f": "여자",
87
+ "woman": "여자",
88
+ "women": "여자",
89
+ "남성": "남자",
90
+ "남": "남자",
91
+ "남자": "남자",
92
+ "male": "남자",
93
+ "m": "남자",
94
+ "man": "남자",
95
+ "men": "남자",
96
+ }
97
+
98
+ PROVINCE_ALIASES = {
99
+ "seoul": "서울",
100
+ "서울시": "서울",
101
+ "서울특별시": "서울",
102
+ "busan": "부산",
103
+ "부산시": "부산",
104
+ "부산광역시": "부산",
105
+ "gyeonggi": "경기",
106
+ "gyeonggi-do": "경기",
107
+ "경기도": "경기",
108
+ "incheon": "인천",
109
+ "daegu": "대구",
110
+ "daejeon": "대전",
111
+ "gwangju": "광주",
112
+ "ulsan": "울산",
113
+ "sejong": "세종",
114
+ "jeju": "제주",
115
+ "jeju-do": "제주",
116
+ "강원도": "강원",
117
+ "chungcheongnam-do": "충청남",
118
+ "chungcheongbuk-do": "충청북",
119
+ "jeollanam-do": "전라남",
120
+ "jeollabuk-do": "전북",
121
+ "gyeongsangnam-do": "경상남",
122
+ "gyeongsangbuk-do": "경상북",
123
+ }
124
+
125
+ CATEGORICAL_FIELDS = [
126
+ "sex",
127
+ "marital_status",
128
+ "military_status",
129
+ "family_type",
130
+ "housing_type",
131
+ "education_level",
132
+ "bachelors_field",
133
+ "occupation",
134
+ "district",
135
+ "province",
136
+ "country",
137
+ ]
138
+
139
+ SAMPLING_FIELDS = [
140
+ "uuid",
141
+ "sex",
142
+ "age",
143
+ "marital_status",
144
+ "military_status",
145
+ "family_type",
146
+ "housing_type",
147
+ "education_level",
148
+ "bachelors_field",
149
+ "occupation",
150
+ "district",
151
+ "province",
152
+ "country",
153
+ *TEXT_FIELDS,
154
+ ]
155
+
156
+
157
+ class PersonaDatasetError(RuntimeError):
158
+ """Raised when the local dataset is unavailable or cannot be queried."""
159
+
160
+
161
+ def _duckdb_module() -> Any:
162
+ try:
163
+ import duckdb # type: ignore
164
+ except ModuleNotFoundError as exc:
165
+ raise PersonaDatasetError(
166
+ "duckdb is required for local Nemotron sampling. Install it in the "
167
+ "Python environment that runs social-sim-ui: python -m pip install duckdb"
168
+ ) from exc
169
+ return duckdb
170
+
171
+
172
+ def _json_url(url: str, timeout: int = 60) -> Any:
173
+ request = urllib.request.Request(url, headers={"User-Agent": "social-sim-ui/0.1"})
174
+ with urllib.request.urlopen(request, timeout=timeout) as response:
175
+ return json.loads(response.read().decode("utf-8"))
176
+
177
+
178
+ def _parquet_glob() -> str:
179
+ return str(PARQUET_DIR / "*.parquet")
180
+
181
+
182
+ def _safe_sql_literal(value: str) -> str:
183
+ return "'" + value.replace("'", "''") + "'"
184
+
185
+
186
+ def _read_manifest() -> dict[str, Any] | None:
187
+ if not MANIFEST_PATH.exists():
188
+ return None
189
+ try:
190
+ return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
191
+ except (OSError, json.JSONDecodeError):
192
+ return None
193
+
194
+
195
+ def fetch_manifest() -> dict[str, Any]:
196
+ """Fetch and persist a parquet URL manifest from Hugging Face."""
197
+
198
+ url = f"https://huggingface.co/api/datasets/{DATASET_ID}/parquet"
199
+ payload = _json_url(url)
200
+ urls = payload.get(DATASET_CONFIG, {}).get(DATASET_SPLIT)
201
+ if not isinstance(urls, list) or not urls:
202
+ raise PersonaDatasetError(f"Could not find {DATASET_CONFIG}/{DATASET_SPLIT} parquet URLs for {DATASET_ID}")
203
+ manifest = {
204
+ "dataset_id": DATASET_ID,
205
+ "config": DATASET_CONFIG,
206
+ "split": DATASET_SPLIT,
207
+ "fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
208
+ "parquet_urls": urls,
209
+ "columns": DATASET_COLUMNS,
210
+ "license": "cc-by-4.0",
211
+ }
212
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
213
+ MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
214
+ return manifest
215
+
216
+
217
+ def download_dataset(*, force: bool = False, limit_shards: int | None = None) -> dict[str, Any]:
218
+ """Download parquet shards locally.
219
+
220
+ ``limit_shards`` is for smoke tests. Production use should leave it unset.
221
+ """
222
+
223
+ manifest = _read_manifest() or fetch_manifest()
224
+ urls = list(manifest.get("parquet_urls") or [])
225
+ if limit_shards is not None:
226
+ urls = urls[: max(0, int(limit_shards))]
227
+ if not urls:
228
+ raise PersonaDatasetError("manifest contains no parquet URLs")
229
+ PARQUET_DIR.mkdir(parents=True, exist_ok=True)
230
+ downloaded: list[dict[str, Any]] = []
231
+ skipped: list[dict[str, Any]] = []
232
+ for idx, url in enumerate(urls):
233
+ target = PARQUET_DIR / f"{idx}.parquet"
234
+ if target.exists() and target.stat().st_size > 0 and not force:
235
+ skipped.append({"shard": idx, "path": str(target), "bytes": target.stat().st_size})
236
+ continue
237
+ tmp = target.with_suffix(".parquet.tmp")
238
+ request = urllib.request.Request(url, headers={"User-Agent": "social-sim-ui/0.1"})
239
+ with urllib.request.urlopen(request, timeout=120) as response, tmp.open("wb") as handle:
240
+ while True:
241
+ chunk = response.read(1024 * 1024)
242
+ if not chunk:
243
+ break
244
+ handle.write(chunk)
245
+ tmp.replace(target)
246
+ downloaded.append({"shard": idx, "path": str(target), "bytes": target.stat().st_size})
247
+ return {
248
+ "dataset_id": DATASET_ID,
249
+ "data_dir": str(DATA_DIR),
250
+ "manifest": str(MANIFEST_PATH),
251
+ "downloaded": downloaded,
252
+ "skipped": skipped,
253
+ "status": dataset_status(),
254
+ }
255
+
256
+
257
+ def dataset_status() -> dict[str, Any]:
258
+ files = sorted(PARQUET_DIR.glob("*.parquet")) if PARQUET_DIR.exists() else []
259
+ total_bytes = sum(path.stat().st_size for path in files if path.exists())
260
+ duckdb_available = True
261
+ duckdb_error = ""
262
+ try:
263
+ _duckdb_module()
264
+ except PersonaDatasetError as exc:
265
+ duckdb_available = False
266
+ duckdb_error = str(exc)
267
+ manifest = _read_manifest()
268
+ expected_shards = len(manifest.get("parquet_urls", [])) if manifest else None
269
+ return {
270
+ "dataset_id": DATASET_ID,
271
+ "config": DATASET_CONFIG,
272
+ "split": DATASET_SPLIT,
273
+ "license": "cc-by-4.0",
274
+ "data_dir": str(DATA_DIR),
275
+ "manifest_exists": MANIFEST_PATH.exists(),
276
+ "parquet_dir": str(PARQUET_DIR),
277
+ "parquet_shards": len(files),
278
+ "expected_shards": expected_shards,
279
+ "download_complete": bool(files) and expected_shards is not None and len(files) >= expected_shards,
280
+ "downloaded_bytes": total_bytes,
281
+ "duckdb_available": duckdb_available,
282
+ "duckdb_error": duckdb_error,
283
+ "duckdb_path": str(DUCKDB_PATH),
284
+ "columns": DATASET_COLUMNS,
285
+ "default_selected_fields": DEFAULT_SELECTED_FIELDS,
286
+ }
287
+
288
+
289
+ def _connect_duckdb() -> Any:
290
+ duckdb = _duckdb_module()
291
+ if not PARQUET_DIR.exists() or not list(PARQUET_DIR.glob("*.parquet")):
292
+ raise PersonaDatasetError(
293
+ f"No local parquet shards found in {PARQUET_DIR}. Run scripts/download_nemotron_personas_korea.py first."
294
+ )
295
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
296
+ conn = duckdb.connect(str(DUCKDB_PATH))
297
+ conn.execute(
298
+ f"CREATE OR REPLACE VIEW personas AS SELECT * FROM read_parquet({_safe_sql_literal(_parquet_glob())})"
299
+ )
300
+ return conn
301
+
302
+
303
+ def dataset_metadata() -> dict[str, Any]:
304
+ status = dataset_status()
305
+ if not status["parquet_shards"]:
306
+ return {**status, "available": False, "error": "dataset parquet files are not downloaded"}
307
+ conn = _connect_duckdb()
308
+ try:
309
+ row_count = int(conn.execute("SELECT count(*) FROM personas").fetchone()[0])
310
+ sex_counts = _top_counts(conn, "sex", 20)
311
+ province_counts = _top_counts(conn, "province", 40)
312
+ district_counts_by_province = _district_counts_by_province(conn)
313
+ education_counts = _top_counts(conn, "education_level", 30)
314
+ occupation_counts = _top_counts(conn, "occupation", 40)
315
+ age_buckets = conn.execute(
316
+ """
317
+ SELECT
318
+ CASE
319
+ WHEN TRY_CAST(age AS INTEGER) IS NULL THEN 'unknown'
320
+ WHEN TRY_CAST(age AS INTEGER) < 20 THEN 'under_20'
321
+ WHEN TRY_CAST(age AS INTEGER) < 30 THEN '20s'
322
+ WHEN TRY_CAST(age AS INTEGER) < 40 THEN '30s'
323
+ WHEN TRY_CAST(age AS INTEGER) < 50 THEN '40s'
324
+ WHEN TRY_CAST(age AS INTEGER) < 60 THEN '50s'
325
+ ELSE '60_plus'
326
+ END AS bucket,
327
+ count(*) AS count
328
+ FROM personas
329
+ GROUP BY bucket
330
+ ORDER BY count DESC
331
+ """
332
+ ).fetchall()
333
+ finally:
334
+ conn.close()
335
+ return {
336
+ **status,
337
+ "available": True,
338
+ "row_count": row_count,
339
+ "sex_counts": sex_counts,
340
+ "province_counts": province_counts,
341
+ "district_counts_by_province": district_counts_by_province,
342
+ "education_counts": education_counts,
343
+ "occupation_counts": occupation_counts,
344
+ "age_buckets": [{"value": value, "count": int(count)} for value, count in age_buckets],
345
+ }
346
+
347
+
348
+ def _top_counts(conn: Any, field: str, limit: int) -> list[dict[str, Any]]:
349
+ if field not in CATEGORICAL_FIELDS:
350
+ return []
351
+ rows = conn.execute(
352
+ f"""
353
+ SELECT {field} AS value, count(*) AS count
354
+ FROM personas
355
+ WHERE {field} IS NOT NULL AND CAST({field} AS VARCHAR) != ''
356
+ GROUP BY {field}
357
+ ORDER BY count DESC, value
358
+ LIMIT ?
359
+ """,
360
+ [int(limit)],
361
+ ).fetchall()
362
+ return [{"value": str(value), "count": int(count)} for value, count in rows]
363
+
364
+
365
+ def _district_counts_by_province(conn: Any) -> dict[str, list[dict[str, Any]]]:
366
+ rows = conn.execute(
367
+ """
368
+ SELECT province, district, count(*) AS count
369
+ FROM personas
370
+ WHERE province IS NOT NULL
371
+ AND district IS NOT NULL
372
+ AND CAST(province AS VARCHAR) != ''
373
+ AND CAST(district AS VARCHAR) != ''
374
+ GROUP BY province, district
375
+ ORDER BY province, count DESC, district
376
+ """
377
+ ).fetchall()
378
+ grouped: dict[str, list[dict[str, Any]]] = {}
379
+ for province, district, count in rows:
380
+ province_text = str(province)
381
+ grouped.setdefault(province_text, []).append({"value": str(district), "count": int(count)})
382
+ return grouped
383
+
384
+
385
+ def build_index() -> dict[str, Any]:
386
+ """Create a small DuckDB database with a persistent parquet-backed view."""
387
+
388
+ conn = _connect_duckdb()
389
+ try:
390
+ row_count = int(conn.execute("SELECT count(*) FROM personas").fetchone()[0])
391
+ conn.execute("CREATE TABLE IF NOT EXISTS dataset_cache_metadata(key VARCHAR PRIMARY KEY, value VARCHAR)")
392
+ conn.execute(
393
+ "INSERT OR REPLACE INTO dataset_cache_metadata VALUES (?, ?)",
394
+ ["row_count", str(row_count)],
395
+ )
396
+ conn.execute(
397
+ "INSERT OR REPLACE INTO dataset_cache_metadata VALUES (?, ?)",
398
+ ["built_at", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())],
399
+ )
400
+ finally:
401
+ conn.close()
402
+ return {"duckdb_path": str(DUCKDB_PATH), "row_count": row_count, "status": dataset_status()}
403
+
404
+
405
+ def sample_personas(spec: dict[str, Any] | None = None) -> dict[str, Any]:
406
+ spec = spec or {}
407
+ total = _bounded_int(spec.get("total_agents"), 10, minimum=1, maximum=_max_dataset_agents(spec))
408
+ seed = str(spec.get("seed", 7))
409
+ selected_fields = _selected_fields(spec.get("selected_fields"))
410
+ base_filters = _normalize_filters(spec.get("filters") if isinstance(spec.get("filters"), dict) else {})
411
+ groups = _normalize_sampling_groups(spec.get("groups") if isinstance(spec.get("groups"), list) else [])
412
+ conn = _connect_duckdb()
413
+ try:
414
+ rows: list[dict[str, Any]] = []
415
+ seen: set[str] = set()
416
+ allocations = _allocate_group_counts(total, groups)
417
+ if allocations:
418
+ for idx, group in enumerate(allocations):
419
+ merged_filters = {**base_filters, **group["filters"]}
420
+ sampled = _query_sample(
421
+ conn,
422
+ count=group["count"],
423
+ seed=f"{seed}:group:{idx}:{group['label']}",
424
+ filters=merged_filters,
425
+ selected_fields=selected_fields,
426
+ exclude_uuids=seen,
427
+ )
428
+ for row in sampled:
429
+ row["_sampling_group"] = group["label"]
430
+ seen.add(str(row.get("uuid", "")))
431
+ rows.extend(sampled)
432
+ if len(rows) < total:
433
+ top_up = _query_sample(
434
+ conn,
435
+ count=total - len(rows),
436
+ seed=f"{seed}:topup",
437
+ filters=base_filters,
438
+ selected_fields=selected_fields,
439
+ exclude_uuids=seen,
440
+ )
441
+ rows.extend(top_up)
442
+ finally:
443
+ conn.close()
444
+ agents = project_rows_to_agents(
445
+ rows[:total],
446
+ scenario_text=str(spec.get("scenario_text") or ""),
447
+ policy_text=str(spec.get("policy_text") or ""),
448
+ decision_task=spec.get("decision_task") if isinstance(spec.get("decision_task"), dict) else None,
449
+ selected_fields=selected_fields,
450
+ )
451
+ return {
452
+ "dataset_id": DATASET_ID,
453
+ "requested_agents": total,
454
+ "sampled_rows": len(rows[:total]),
455
+ "selected_fields": selected_fields,
456
+ "filters": base_filters,
457
+ "groups": groups,
458
+ "agents": agents,
459
+ "population": {
460
+ "total_agents": len(agents),
461
+ "groups": _groups_from_agents(agents),
462
+ "persona_source": {
463
+ "type": "nemotron_korea",
464
+ "dataset_id": DATASET_ID,
465
+ "selected_fields": selected_fields,
466
+ "filters": base_filters,
467
+ "groups": groups,
468
+ },
469
+ },
470
+ }
471
+
472
+
473
+ def _max_dataset_agents(spec: dict[str, Any]) -> int:
474
+ try:
475
+ value = int(spec.get("max_agents", os.getenv("SOCIAL_SIM_DATASET_AGENT_LIMIT", "5000")))
476
+ except (TypeError, ValueError):
477
+ value = 5000
478
+ return max(1, min(100000, value))
479
+
480
+
481
+ def _selected_fields(raw: Any) -> list[str]:
482
+ if not isinstance(raw, list):
483
+ return list(DEFAULT_SELECTED_FIELDS)
484
+ fields = [str(field) for field in raw if str(field) in TEXT_FIELDS]
485
+ return fields or list(DEFAULT_SELECTED_FIELDS)
486
+
487
+
488
+ def _normalize_filters(filters: dict[str, Any]) -> dict[str, Any]:
489
+ normalized: dict[str, Any] = {}
490
+ for key, value in filters.items():
491
+ normalized[str(key)] = _normalize_filter_value(str(key), value)
492
+ return normalized
493
+
494
+
495
+ def _normalize_sampling_groups(groups: list[Any]) -> list[Any]:
496
+ normalized: list[Any] = []
497
+ for item in groups:
498
+ if not isinstance(item, dict):
499
+ continue
500
+ group = dict(item)
501
+ if isinstance(group.get("filters"), dict):
502
+ group["filters"] = _normalize_filters(group["filters"])
503
+ normalized.append(group)
504
+ return normalized
505
+
506
+
507
+ def _normalize_filter_value(field: str, value: Any) -> Any:
508
+ if isinstance(value, list):
509
+ return [_normalize_filter_value(field, item) for item in value]
510
+ text = str(value).strip() if value is not None else ""
511
+ if not text:
512
+ return value
513
+ lowered = text.lower()
514
+ if field == "sex":
515
+ return SEX_ALIASES.get(lowered, SEX_ALIASES.get(text, text))
516
+ if field == "province":
517
+ return PROVINCE_ALIASES.get(lowered, PROVINCE_ALIASES.get(text, text))
518
+ return value
519
+
520
+
521
+ def _allocate_group_counts(total: int, groups: list[Any]) -> list[dict[str, Any]]:
522
+ valid: list[dict[str, Any]] = []
523
+ for idx, item in enumerate(groups):
524
+ if not isinstance(item, dict):
525
+ continue
526
+ filters = _normalize_filters(item.get("filters") if isinstance(item.get("filters"), dict) else {})
527
+ label = str(item.get("label") or f"group_{idx + 1}")
528
+ count = _int_or_none(item.get("count"))
529
+ ratio = _float_or_none(item.get("ratio"))
530
+ valid.append({"label": label, "filters": filters, "count": count, "ratio": ratio})
531
+ if not valid:
532
+ return []
533
+ fixed_total = sum(item["count"] for item in valid if isinstance(item.get("count"), int) and item["count"] > 0)
534
+ ratio_items = [item for item in valid if not isinstance(item.get("count"), int) or item["count"] <= 0]
535
+ remaining = max(0, total - fixed_total)
536
+ ratio_sum = sum(max(0.0, float(item.get("ratio") or 0.0)) for item in ratio_items)
537
+ allocated: list[dict[str, Any]] = []
538
+ for item in valid:
539
+ if isinstance(item.get("count"), int) and item["count"] > 0:
540
+ count = min(item["count"], total)
541
+ fraction = 0.0
542
+ elif ratio_sum > 0:
543
+ raw = remaining * max(0.0, float(item.get("ratio") or 0.0)) / ratio_sum
544
+ count = int(math.floor(raw))
545
+ fraction = raw - count
546
+ else:
547
+ raw = remaining / max(1, len(ratio_items))
548
+ count = int(math.floor(raw))
549
+ fraction = raw - count
550
+ allocated.append({"label": item["label"], "filters": item["filters"], "count": count, "_fraction": fraction})
551
+ while sum(item["count"] for item in allocated) < total:
552
+ target = max(allocated, key=lambda item: item.get("_fraction", 0.0))
553
+ target["count"] += 1
554
+ target["_fraction"] = 0.0
555
+ while sum(item["count"] for item in allocated) > total:
556
+ target = max(allocated, key=lambda item: item["count"])
557
+ target["count"] -= 1
558
+ return [{key: value for key, value in item.items() if key != "_fraction" and item["count"] > 0} for item in allocated]
559
+
560
+
561
+ def _query_sample(
562
+ conn: Any,
563
+ *,
564
+ count: int,
565
+ seed: str,
566
+ filters: dict[str, Any],
567
+ selected_fields: list[str],
568
+ exclude_uuids: set[str],
569
+ ) -> list[dict[str, Any]]:
570
+ if count <= 0:
571
+ return []
572
+ columns = _query_columns(selected_fields)
573
+ where_sql, params = _where_clause(filters, exclude_uuids)
574
+ params.extend([str(seed), int(count)])
575
+ rows = conn.execute(
576
+ f"""
577
+ SELECT {", ".join(columns)}
578
+ FROM personas
579
+ {where_sql}
580
+ ORDER BY hash(CAST(uuid AS VARCHAR) || CAST(? AS VARCHAR))
581
+ LIMIT ?
582
+ """,
583
+ params,
584
+ ).fetchall()
585
+ names = [column.split(" AS ")[-1] if " AS " in column else column for column in columns]
586
+ return [dict(zip(names, row)) for row in rows]
587
+
588
+
589
+ def _query_columns(selected_fields: list[str]) -> list[str]:
590
+ base = [
591
+ "uuid",
592
+ "sex",
593
+ "age",
594
+ "marital_status",
595
+ "military_status",
596
+ "family_type",
597
+ "housing_type",
598
+ "education_level",
599
+ "bachelors_field",
600
+ "occupation",
601
+ "district",
602
+ "province",
603
+ "country",
604
+ ]
605
+ for field in selected_fields:
606
+ if field not in base:
607
+ base.append(field)
608
+ return base
609
+
610
+
611
+ def _where_clause(filters: dict[str, Any], exclude_uuids: set[str]) -> tuple[str, list[Any]]:
612
+ clauses: list[str] = []
613
+ params: list[Any] = []
614
+ for field in CATEGORICAL_FIELDS:
615
+ if field not in filters:
616
+ continue
617
+ value = filters.get(field)
618
+ if isinstance(value, list):
619
+ clean_values = [str(item) for item in value if str(item).strip()]
620
+ if clean_values:
621
+ placeholders = ", ".join("?" for _ in clean_values)
622
+ clauses.append(f"CAST({field} AS VARCHAR) IN ({placeholders})")
623
+ params.extend(clean_values)
624
+ elif value not in {None, ""}:
625
+ clauses.append(f"CAST({field} AS VARCHAR) = ?")
626
+ params.append(str(value))
627
+ age_min = _int_or_none(filters.get("age_min"))
628
+ age_max = _int_or_none(filters.get("age_max"))
629
+ if age_min is not None:
630
+ clauses.append("TRY_CAST(age AS INTEGER) >= ?")
631
+ params.append(age_min)
632
+ if age_max is not None:
633
+ clauses.append("TRY_CAST(age AS INTEGER) <= ?")
634
+ params.append(age_max)
635
+ occupation_query = str(filters.get("occupation_query") or "").strip().lower()
636
+ if occupation_query:
637
+ clauses.append("(lower(CAST(occupation AS VARCHAR)) LIKE ? OR lower(CAST(professional_persona AS VARCHAR)) LIKE ?)")
638
+ pattern = f"%{occupation_query}%"
639
+ params.extend([pattern, pattern])
640
+ keyword_query = str(filters.get("keyword_query") or "").strip().lower()
641
+ if keyword_query:
642
+ text_expr = " || ' ' || ".join(f"coalesce(CAST({field} AS VARCHAR), '')" for field in TEXT_FIELDS[:7])
643
+ clauses.append(f"lower({text_expr}) LIKE ?")
644
+ params.append(f"%{keyword_query}%")
645
+ if exclude_uuids:
646
+ placeholders = ", ".join("?" for _ in exclude_uuids)
647
+ clauses.append(f"CAST(uuid AS VARCHAR) NOT IN ({placeholders})")
648
+ params.extend(sorted(exclude_uuids))
649
+ if not clauses:
650
+ return "", params
651
+ return "WHERE " + " AND ".join(clauses), params
652
+
653
+
654
+ def project_rows_to_agents(
655
+ rows: list[dict[str, Any]],
656
+ *,
657
+ scenario_text: str = "",
658
+ policy_text: str = "",
659
+ decision_task: dict[str, Any] | None = None,
660
+ selected_fields: list[str] | None = None,
661
+ ) -> list[dict[str, Any]]:
662
+ selected_fields = selected_fields or DEFAULT_SELECTED_FIELDS
663
+ agents: list[dict[str, Any]] = []
664
+ for idx, row in enumerate(rows, start=1):
665
+ text = " ".join(str(row.get(field) or "") for field in TEXT_FIELDS)
666
+ dimensions = _infer_behavior_dimensions(row, text)
667
+ baseline = _baseline_from_dimensions(dimensions)
668
+ location = " ".join(str(row.get(key) or "").strip() for key in ["province", "district"] if row.get(key)).strip()
669
+ occupation = str(row.get("occupation") or "").strip()
670
+ age = str(row.get("age") or "").strip()
671
+ sex = str(row.get("sex") or "").strip()
672
+ role_parts = [part for part in [location, f"{age}세" if age else "", sex, occupation] if part]
673
+ role = " ".join(role_parts) or "Nemotron persona participant"
674
+ persona_type = _slugify(
675
+ "_".join(str(part) for part in [row.get("province"), row.get("occupation"), row.get("age")] if part),
676
+ f"nemotron_persona_{idx}",
677
+ )
678
+ prompt_card = build_prompt_card(row, selected_fields=selected_fields)
679
+ agents.append(
680
+ {
681
+ "agent_id": f"agent_{idx:04d}",
682
+ "name": f"Persona {idx:04d}",
683
+ "persona_type": persona_type,
684
+ "role": role,
685
+ "goal": _goal_from_context(row, scenario_text, policy_text, decision_task),
686
+ "persona_description": prompt_card,
687
+ "baseline_usage": baseline,
688
+ "behavior_dimensions": dimensions,
689
+ "traits": _traits_from_row(row, dimensions),
690
+ "state": {
691
+ "activation": "eligible",
692
+ "last_numeric_decision": None,
693
+ "memory_summary": "",
694
+ },
695
+ "persona_source": {
696
+ "type": "nemotron_korea",
697
+ "dataset_id": DATASET_ID,
698
+ "uuid": str(row.get("uuid") or ""),
699
+ "sampling_group": str(row.get("_sampling_group") or ""),
700
+ "selected_fields": selected_fields,
701
+ },
702
+ }
703
+ )
704
+ return agents
705
+
706
+
707
+ def build_prompt_card(row: dict[str, Any], *, selected_fields: list[str] | None = None, field_limit: int = 360) -> str:
708
+ selected_fields = selected_fields or DEFAULT_SELECTED_FIELDS
709
+ lines = [
710
+ f"성별: {_clip(row.get('sex'), 80)}",
711
+ f"연령: {_clip(row.get('age'), 80)}",
712
+ f"지역: {_clip(' '.join(str(row.get(key) or '') for key in ['country', 'province', 'district']).strip(), 140)}",
713
+ f"학력/전공: {_clip(' / '.join(str(row.get(key) or '') for key in ['education_level', 'bachelors_field']).strip(' /'), 180)}",
714
+ f"직업: {_clip(row.get('occupation'), 180)}",
715
+ f"가족/주거: {_clip(' / '.join(str(row.get(key) or '') for key in ['marital_status', 'family_type', 'housing_type']).strip(' /'), 180)}",
716
+ ]
717
+ for field in selected_fields:
718
+ value = _clip(row.get(field), field_limit)
719
+ if value:
720
+ lines.append(f"{field}: {value}")
721
+ return "\n".join(line for line in lines if not line.endswith(": "))
722
+
723
+
724
+ def _infer_behavior_dimensions(row: dict[str, Any], text: str) -> dict[str, float]:
725
+ lowered = text.lower()
726
+ age = _int_or_none(row.get("age")) or 40
727
+ occupation = str(row.get("occupation") or "").lower()
728
+ education = str(row.get("education_level") or "").lower()
729
+ family = str(row.get("family_type") or "").lower()
730
+ cooperation_terms = ["봉사", "지역", "가족", "교육", "상담", "간호", "의료", "협력", "community", "team", "mentor"]
731
+ self_terms = ["창업", "투자", "영업", "관리자", "경영", "ambition", "entrepreneur", "business", "sales", "finance"]
732
+ risk_terms = ["안전", "의료", "법", "회계", "보험", "품질", "security", "compliance", "risk"]
733
+ authority_terms = ["군", "공무", "교사", "관리", "법", "행정", "manager", "government", "military", "teacher"]
734
+ social_terms = ["예술", "스포츠", "여행", "요리", "가족", "커뮤니티", "media", "arts", "sports", "travel"]
735
+ cooperation = 0.45 + 0.08 * _contains_any(lowered, cooperation_terms) + 0.06 * bool(family)
736
+ self_interest = 0.45 + 0.1 * _contains_any(lowered + " " + occupation, self_terms)
737
+ risk_aversion = 0.42 + 0.12 * _contains_any(lowered + " " + occupation, risk_terms) + min(0.16, max(0, age - 35) / 200)
738
+ authority = 0.42 + 0.12 * _contains_any(lowered + " " + occupation + " " + education, authority_terms) + min(0.12, max(0, age - 30) / 250)
739
+ social = 0.42 + 0.12 * _contains_any(lowered, social_terms)
740
+ if age < 30:
741
+ social += 0.05
742
+ risk_aversion -= 0.03
743
+ if "single" in family or "미혼" in family:
744
+ self_interest += 0.04
745
+ return {
746
+ "cooperation": _clamp01(cooperation),
747
+ "self_interest": _clamp01(self_interest),
748
+ "risk_aversion": _clamp01(risk_aversion),
749
+ "authority_respect": _clamp01(authority),
750
+ "social_influence": _clamp01(social),
751
+ }
752
+
753
+
754
+ def _traits_from_row(row: dict[str, Any], dimensions: dict[str, float]) -> dict[str, Any]:
755
+ return {
756
+ "demographic": {
757
+ "sex": row.get("sex"),
758
+ "age": row.get("age"),
759
+ "province": row.get("province"),
760
+ "district": row.get("district"),
761
+ "occupation": row.get("occupation"),
762
+ "education_level": row.get("education_level"),
763
+ },
764
+ "behavior_projection": dimensions,
765
+ }
766
+
767
+
768
+ def _goal_from_context(
769
+ row: dict[str, Any],
770
+ scenario_text: str,
771
+ policy_text: str,
772
+ decision_task: dict[str, Any] | None,
773
+ ) -> str:
774
+ task_text = ""
775
+ if decision_task:
776
+ task_text = str(decision_task.get("action_prompt") or decision_task.get("metric_name") or "")
777
+ occupation = str(row.get("occupation") or "participant").strip()
778
+ context = _clip(task_text or scenario_text or policy_text, 220)
779
+ if context:
780
+ return f"{occupation} 관점에서 {context}에 대해 개인 상황과 사회적 결과를 함께 고려한다."
781
+ return f"{occupation} 관점에서 개인 상황과 사회적 결과를 함께 고려한다."
782
+
783
+
784
+ def _baseline_from_dimensions(dimensions: dict[str, float]) -> int:
785
+ raw = 6.0 + 3.2 * dimensions["self_interest"] - 2.0 * dimensions["cooperation"] - 1.4 * dimensions["risk_aversion"]
786
+ return max(0, min(12, int(round(raw))))
787
+
788
+
789
+ def _groups_from_agents(agents: list[dict[str, Any]]) -> dict[str, int]:
790
+ groups: dict[str, int] = {}
791
+ for agent in agents:
792
+ key = str(agent.get("persona_type") or "nemotron_persona")
793
+ groups[key] = groups.get(key, 0) + 1
794
+ return groups
795
+
796
+
797
+ def _contains_any(text: str, terms: list[str]) -> int:
798
+ return 1 if any(term.lower() in text for term in terms) else 0
799
+
800
+
801
+ def _clip(value: Any, limit: int) -> str:
802
+ text = str(value or "").strip()
803
+ if len(text) <= limit:
804
+ return text
805
+ return text[: limit - 3].rstrip() + "..."
806
+
807
+
808
+ def _slugify(value: Any, fallback: str) -> str:
809
+ text = str(value or "").strip().lower()
810
+ text = re.sub(r"[^0-9a-zA-Z가-힣]+", "_", text).strip("_")
811
+ if not text:
812
+ return fallback
813
+ return text[:80]
814
+
815
+
816
+ def _bounded_int(value: Any, default: int, *, minimum: int, maximum: int) -> int:
817
+ parsed = _int_or_none(value)
818
+ if parsed is None:
819
+ parsed = default
820
+ return max(minimum, min(maximum, parsed))
821
+
822
+
823
+ def _int_or_none(value: Any) -> int | None:
824
+ try:
825
+ return int(float(value))
826
+ except (TypeError, ValueError):
827
+ return None
828
+
829
+
830
+ def _float_or_none(value: Any) -> float | None:
831
+ try:
832
+ return float(value)
833
+ except (TypeError, ValueError):
834
+ return None
835
+
836
+
837
+ def _clamp01(value: float) -> float:
838
+ return round(max(0.0, min(1.0, float(value))), 3)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ openai>=1.0.0
2
+ duckdb>=1.0.0
scripts/build_nemotron_persona_index.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build/check the local DuckDB view for Nemotron-Personas-Korea."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ APP_DIR = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(APP_DIR))
13
+
14
+ from persona_dataset import build_index, dataset_metadata # noqa: E402
15
+
16
+
17
+ def main() -> int:
18
+ index_payload = build_index()
19
+ metadata = dataset_metadata()
20
+ print(json.dumps({"index": index_payload, "metadata": metadata}, indent=2, ensure_ascii=False))
21
+ return 0
22
+
23
+
24
+ if __name__ == "__main__":
25
+ raise SystemExit(main())
scripts/download_nemotron_personas_korea.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Download nvidia/Nemotron-Personas-Korea parquet shards for local use."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ APP_DIR = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(APP_DIR))
14
+
15
+ from persona_dataset import download_dataset, fetch_manifest # noqa: E402
16
+
17
+
18
+ def main() -> int:
19
+ parser = argparse.ArgumentParser(description=__doc__)
20
+ parser.add_argument("--force", action="store_true", help="Redownload shards that already exist.")
21
+ parser.add_argument(
22
+ "--limit-shards",
23
+ type=int,
24
+ default=None,
25
+ help="Download only the first N shards for smoke tests. Omit for the full dataset.",
26
+ )
27
+ parser.add_argument("--manifest-only", action="store_true", help="Fetch manifest without downloading parquet.")
28
+ args = parser.parse_args()
29
+ if args.manifest_only:
30
+ payload = fetch_manifest()
31
+ else:
32
+ payload = download_dataset(force=args.force, limit_shards=args.limit_shards)
33
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
34
+ return 0
35
+
36
+
37
+ if __name__ == "__main__":
38
+ raise SystemExit(main())
scripts/test_silicon_llm_runtime.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Unit tests for dynamic LLM-backed silicon sampling runtime."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ import unittest
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(ROOT))
15
+
16
+ from silicon_llm import ( # noqa: E402
17
+ build_silicon_llm_response_contract,
18
+ parse_silicon_agent_response,
19
+ run_silicon_llm_sampling,
20
+ )
21
+
22
+
23
+ QUESTIONS = [
24
+ {
25
+ "id": "q_likert_5",
26
+ "title": "현 정부 국정 운영을 어떻게 평가하십니까?",
27
+ "kind": "likert",
28
+ "scale": 5,
29
+ "lowLabel": "매우 부정",
30
+ "highLabel": "매우 긍정",
31
+ },
32
+ {
33
+ "id": "q_likert_4",
34
+ "title": "지역 의료 접근성에 만족하십니까?",
35
+ "kind": "likert",
36
+ "scale": 4,
37
+ },
38
+ {
39
+ "id": "q_open",
40
+ "title": "가장 먼저 해결해야 할 문제는 무엇입니까?",
41
+ "kind": "open",
42
+ },
43
+ ]
44
+
45
+
46
+ class FakeLLM:
47
+ def __init__(self) -> None:
48
+ self.calls: list[dict[str, Any]] = []
49
+
50
+ def complete_agent(self, *, agent: dict[str, Any], questions: list[dict[str, Any]], response_contract: dict[str, Any]) -> str:
51
+ self.calls.append({"agent": agent, "questions": questions, "response_contract": response_contract})
52
+ return json.dumps(
53
+ {
54
+ "answers": {
55
+ "q_likert_5": {"value": 4, "reason": "정책 평가는 대체로 긍정적입니다."},
56
+ "q_likert_4": {"value": 2, "reason": "의료 접근성은 지역에 따라 부족합니다."},
57
+ "q_open": {"text": "물가와 의료 접근성 개선이 가장 시급합니다.", "theme": "의료"},
58
+ }
59
+ },
60
+ ensure_ascii=False,
61
+ )
62
+
63
+
64
+ class SiliconLLMRuntimeTests(unittest.TestCase):
65
+ def test_contract_is_dynamic_for_question_mix(self) -> None:
66
+ contract = build_silicon_llm_response_contract(QUESTIONS)
67
+
68
+ answer_properties = contract["schema"]["properties"]["answers"]["properties"]
69
+ self.assertEqual(set(answer_properties), {"q_likert_5", "q_likert_4", "q_open"})
70
+ self.assertEqual(answer_properties["q_likert_5"]["properties"]["value"]["minimum"], 1)
71
+ self.assertEqual(answer_properties["q_likert_5"]["properties"]["value"]["maximum"], 5)
72
+ self.assertEqual(answer_properties["q_likert_4"]["properties"]["value"]["maximum"], 4)
73
+ self.assertIn("text", answer_properties["q_open"]["required"])
74
+
75
+ def test_parser_extracts_all_dynamic_answers_from_fenced_json(self) -> None:
76
+ raw = """
77
+ 생각 과정은 생략합니다.
78
+ ```json
79
+ {
80
+ "answers": {
81
+ "q_likert_5": {"value": "5", "reason": "강한 긍정"},
82
+ "q_likert_4": {"score": 3, "reason": "보통 이상"},
83
+ "q_open": {"answer": "교통과 돌봄이 필요합니다", "topic": "교통"}
84
+ }
85
+ }
86
+ ```
87
+ """
88
+ parsed = parse_silicon_agent_response(raw, QUESTIONS, respondent_id="R0001")
89
+
90
+ self.assertEqual(len(parsed["likertAnswers"]), 2)
91
+ self.assertEqual(len(parsed["openAnswers"]), 1)
92
+ self.assertEqual(parsed["likertAnswers"][0]["value"], 5)
93
+ self.assertEqual(parsed["likertAnswers"][1]["value"], 3)
94
+ self.assertEqual(parsed["openAnswers"][0]["theme"], "교통")
95
+
96
+ def test_runtime_calls_one_llm_agent_for_all_questions_and_builds_stats(self) -> None:
97
+ fake = FakeLLM()
98
+ payload = {
99
+ "config": {
100
+ "sampleSize": 2,
101
+ "genders": [{"id": "male", "enabled": True, "weight": 1}, {"id": "female", "enabled": True, "weight": 1}],
102
+ "ages": [{"id": "30s", "enabled": True, "weight": 2}],
103
+ "locations": [{"id": "seoul", "enabled": True, "weight": 2}],
104
+ "locationOptions": [{"id": "seoul", "label": "서울", "parentRegion": "seoul", "level": "sido", "defaultWeight": 100, "short": "서울", "group": "시도"}],
105
+ "personaAttributes": [{"id": "occ_office", "enabled": True, "weight": 2}],
106
+ "nemotronFields": ["persona", "sex", "age"],
107
+ "questions": QUESTIONS,
108
+ "seed": 42,
109
+ },
110
+ "execution": {"max_agents": 2},
111
+ }
112
+
113
+ result = run_silicon_llm_sampling(payload, llm=fake)
114
+
115
+ self.assertEqual(len(fake.calls), 2)
116
+ self.assertTrue(all(len(call["questions"]) == 3 for call in fake.calls))
117
+ self.assertEqual(len(result["respondents"]), 2)
118
+ self.assertEqual(len(result["likertAnswers"]), 4)
119
+ self.assertEqual(len(result["openAnswers"]), 2)
120
+ self.assertEqual([stat["questionId"] for stat in result["questionStats"]], ["q_likert_5", "q_likert_4", "q_open"])
121
+ self.assertEqual(result["questionStats"][0]["distribution"][3]["count"], 2)
122
+ self.assertEqual(result["regionStats"][0]["respondents"], 2)
123
+ self.assertEqual(result["llmTrace"]["calls"], 2)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main(verbosity=2)
scripts/validate_silicon_llm_api.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run small real-API validation for dynamic silicon sampling."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ sys.path.insert(0, str(ROOT))
16
+
17
+ from silicon_llm import resolve_openai_api_key, run_silicon_llm_sampling # noqa: E402
18
+
19
+
20
+ def main() -> int:
21
+ parser = argparse.ArgumentParser()
22
+ parser.add_argument("--agents", type=int, default=5)
23
+ parser.add_argument("--model", default="gpt-4o-mini")
24
+ parser.add_argument("--out", default="")
25
+ args = parser.parse_args()
26
+ if not resolve_openai_api_key():
27
+ print(json.dumps({"ok": False, "error": "OPENAI_API_KEY or .env OPENAI_API_KEY is required"}, ensure_ascii=False, indent=2))
28
+ return 1
29
+ out_dir = Path(args.out) if args.out else ROOT / "runs" / f"silicon_llm_api_validation_{timestamp()}"
30
+ out_dir.mkdir(parents=True, exist_ok=True)
31
+ report: dict[str, Any] = {"ok": False, "model": args.model, "agents": args.agents, "cases": []}
32
+ for case in validation_cases():
33
+ payload = base_payload(args.agents, case["questions"], model=args.model)
34
+ started = time.time()
35
+ try:
36
+ result = run_silicon_llm_sampling(payload)
37
+ checks = validate_result(case["case_id"], result, case["questions"], args.agents)
38
+ report["cases"].append(
39
+ {
40
+ "case_id": case["case_id"],
41
+ "ok": all(check["ok"] for check in checks),
42
+ "elapsed_seconds": round(time.time() - started, 2),
43
+ "questions": [{"id": item["id"], "kind": item["kind"], "scale": item.get("scale")} for item in case["questions"]],
44
+ "checks": checks,
45
+ "llmTrace": {
46
+ key: value
47
+ for key, value in result.get("llmTrace", {}).items()
48
+ if key != "rawResponses"
49
+ },
50
+ "sample_open_answers": result.get("openAnswers", [])[:3],
51
+ "questionStats": result.get("questionStats", []),
52
+ }
53
+ )
54
+ except Exception as exc: # noqa: BLE001
55
+ report["cases"].append({"case_id": case["case_id"], "ok": False, "error": f"{type(exc).__name__}: {exc}"})
56
+ report["ok"] = bool(report["cases"]) and all(case.get("ok") for case in report["cases"])
57
+ (out_dir / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
58
+ print(json.dumps({"ok": report["ok"], "out": str(out_dir / "report.json"), "cases": report["cases"]}, ensure_ascii=False, indent=2))
59
+ return 0 if report["ok"] else 1
60
+
61
+
62
+ def validation_cases() -> list[dict[str, Any]]:
63
+ return [
64
+ {
65
+ "case_id": "mixed_policy_trust",
66
+ "questions": [
67
+ likert("approval_5", "현 정부 국정 운영을 어떻게 평가하십니까?", 5),
68
+ likert("medical_access_4", "거주 지역 의료 접근성에 만족하십니까?", 4),
69
+ open_q("local_problem", "거주 지역에서 가장 먼저 해결해야 할 문제는 무엇입니까?"),
70
+ ],
71
+ },
72
+ {
73
+ "case_id": "open_only_priorities",
74
+ "questions": [
75
+ open_q("top_issue", "한국 사회가 가장 먼저 해결해야 할 문제는 무엇이라고 보십니까?"),
76
+ open_q("policy_request", "새 정부나 지방자치단체에 가장 바라는 정책을 적어주십시오."),
77
+ ],
78
+ },
79
+ {
80
+ "case_id": "multi_scale_likert",
81
+ "questions": [
82
+ likert("vote_turnout_4", "다음 지방선거에 투표할 가능성이 얼마나 높습니까?", 4),
83
+ likert("party_reflects_5", "주요 정당이 국민 의견을 잘 반영한다고 보십니까?", 5),
84
+ likert("climate_cost_7", "탄소 감축 비용 부담 정책에 어느 정도 동의하십니까?", 7),
85
+ ],
86
+ },
87
+ {
88
+ "case_id": "custom_korean_mix",
89
+ "questions": [
90
+ likert("custom_transport_safety", "출퇴근길 대중교통 안전에 만족하십니까?", 5),
91
+ open_q("custom_one_sentence", "본인의 생활에서 가장 크게 체감되는 정책 문제를 한 문장으로 적어주십시오."),
92
+ likert("custom_media_trust", "온라인 뉴스와 유튜브 정치 정보를 신뢰하십니까?", 5),
93
+ ],
94
+ },
95
+ {
96
+ "case_id": "many_questions_mix",
97
+ "questions": [
98
+ likert("economy_next_year", "향후 1년 한국 경제가 좋아질 것이라고 보십니까?", 5),
99
+ likert("household_life", "현재 가계 생활 형편에 얼마나 만족하십니까?", 5),
100
+ likert("institution_trust", "국회, 언론, 행정부 등 공공기관을 전반적으로 신뢰하십니까?", 5),
101
+ open_q("free_reason", "그렇게 응답한 가장 큰 이유를 적어주십시오."),
102
+ ],
103
+ },
104
+ ]
105
+
106
+
107
+ def likert(question_id: str, title: str, scale: int) -> dict[str, Any]:
108
+ return {"id": question_id, "title": title, "source": "API validation", "category": "검증", "kind": "likert", "scale": scale}
109
+
110
+
111
+ def open_q(question_id: str, title: str) -> dict[str, Any]:
112
+ return {"id": question_id, "title": title, "source": "API validation", "category": "검증", "kind": "open"}
113
+
114
+
115
+ def base_payload(agent_count: int, questions: list[dict[str, Any]], *, model: str) -> dict[str, Any]:
116
+ return {
117
+ "config": {
118
+ "sampleSize": agent_count,
119
+ "genders": [{"id": "male", "enabled": True, "weight": agent_count // 2}, {"id": "female", "enabled": True, "weight": agent_count - agent_count // 2}],
120
+ "ages": [{"id": "30s", "enabled": True, "weight": 2}, {"id": "50s", "enabled": True, "weight": agent_count - 2}],
121
+ "locations": [{"id": "seoul", "enabled": True, "weight": 3}, {"id": "busan", "enabled": True, "weight": agent_count - 3}],
122
+ "locationOptions": [
123
+ {"id": "seoul", "label": "서울", "parentRegion": "seoul", "level": "sido", "defaultWeight": 60, "short": "서울", "group": "시도"},
124
+ {"id": "busan", "label": "부산", "parentRegion": "busan", "level": "sido", "defaultWeight": 40, "short": "부산", "group": "시도"},
125
+ ],
126
+ "personaAttributes": [
127
+ {"id": "occ_office", "enabled": True, "weight": 2},
128
+ {"id": "occ_student", "enabled": True, "weight": agent_count - 2},
129
+ {"id": "edu_bachelor", "enabled": True, "weight": agent_count},
130
+ ],
131
+ "nemotronFields": ["persona", "sex", "age", "province", "occupation", "education_level"],
132
+ "questions": questions,
133
+ "seed": 20260630,
134
+ },
135
+ "execution": {"max_agents": agent_count, "model": model, "timeout_seconds": 90},
136
+ }
137
+
138
+
139
+ def validate_result(case_id: str, result: dict[str, Any], questions: list[dict[str, Any]], agent_count: int) -> list[dict[str, Any]]:
140
+ checks: list[dict[str, Any]] = []
141
+ likert_questions = [item for item in questions if item["kind"] == "likert"]
142
+ open_questions = [item for item in questions if item["kind"] == "open"]
143
+ checks.append(check("respondent_count", len(result.get("respondents", [])) == agent_count, {"count": len(result.get("respondents", [])), "expected": agent_count}))
144
+ checks.append(check("one_llm_call_per_agent", result.get("llmTrace", {}).get("calls") == agent_count, result.get("llmTrace", {})))
145
+ checks.append(check("all_questions_in_config", [item["id"] for item in result.get("config", {}).get("questions", [])] == [item["id"] for item in questions], None))
146
+ checks.append(check("likert_answer_count", len(result.get("likertAnswers", [])) == agent_count * len(likert_questions), {"count": len(result.get("likertAnswers", [])), "expected": agent_count * len(likert_questions)}))
147
+ checks.append(check("open_answer_count", len(result.get("openAnswers", [])) == agent_count * len(open_questions), {"count": len(result.get("openAnswers", [])), "expected": agent_count * len(open_questions)}))
148
+ checks.append(check("question_stats_count", len(result.get("questionStats", [])) == len(questions), {"count": len(result.get("questionStats", [])), "expected": len(questions)}))
149
+ checks.append(check("parse_issues_empty", not result.get("llmTrace", {}).get("parseIssues"), result.get("llmTrace", {}).get("parseIssues")))
150
+ for question in likert_questions:
151
+ question_id = question["id"]
152
+ scale = int(question.get("scale") or 5)
153
+ values = [int(answer["value"]) for answer in result.get("likertAnswers", []) if answer.get("questionId") == question_id]
154
+ stat = next((item for item in result.get("questionStats", []) if item.get("questionId") == question_id), {})
155
+ checks.append(check(f"{case_id}:{question_id}:value_range", len(values) == agent_count and all(1 <= value <= scale for value in values), {"values": values, "scale": scale}))
156
+ checks.append(check(f"{case_id}:{question_id}:distribution_sum", sum(item.get("count", 0) for item in stat.get("distribution", [])) == agent_count, stat.get("distribution")))
157
+ for question in open_questions:
158
+ answers = [answer for answer in result.get("openAnswers", []) if answer.get("questionId") == question["id"]]
159
+ checks.append(check(f"{case_id}:{question['id']}:open_text_theme", len(answers) == agent_count and all(answer.get("text") and answer.get("theme") for answer in answers), answers[:2]))
160
+ expected_primary = next((item["id"] for item in likert_questions), None)
161
+ checks.append(check("primary_question_dynamic", result.get("primaryQuestionId") == expected_primary, {"actual": result.get("primaryQuestionId"), "expected": expected_primary}))
162
+ return checks
163
+
164
+
165
+ def check(name: str, ok: bool, detail: Any) -> dict[str, Any]:
166
+ return {"name": name, "ok": bool(ok), "detail": detail}
167
+
168
+
169
+ def timestamp() -> str:
170
+ return time.strftime("%Y%m%d%H%M%S")
171
+
172
+
173
+ if __name__ == "__main__":
174
+ raise SystemExit(main())
server.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from http import HTTPStatus
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from pathlib import Path
8
+ from urllib.parse import urlparse
9
+
10
+ from persona_dataset import PersonaDatasetError, dataset_metadata, dataset_status, sample_personas
11
+ from silicon_llm import resolve_openai_api_key, run_silicon_llm_sampling
12
+
13
+
14
+ APP_DIR = Path(__file__).resolve().parent
15
+ STATIC_DIR = APP_DIR / "static" / "immersive"
16
+
17
+
18
+ def read_body(handler: BaseHTTPRequestHandler) -> dict:
19
+ length = int(handler.headers.get("content-length") or 0)
20
+ if length <= 0:
21
+ return {}
22
+ raw = handler.rfile.read(length).decode("utf-8")
23
+ return json.loads(raw) if raw else {}
24
+
25
+
26
+ def write_json(handler: BaseHTTPRequestHandler, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
27
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
28
+ handler.send_response(status)
29
+ handler.send_header("content-type", "application/json; charset=utf-8")
30
+ handler.send_header("content-length", str(len(data)))
31
+ handler.end_headers()
32
+ handler.wfile.write(data)
33
+
34
+
35
+ def content_type(path: Path) -> str:
36
+ suffix = path.suffix.lower()
37
+ return {
38
+ ".html": "text/html; charset=utf-8",
39
+ ".js": "text/javascript; charset=utf-8",
40
+ ".css": "text/css; charset=utf-8",
41
+ ".json": "application/json; charset=utf-8",
42
+ ".svg": "image/svg+xml",
43
+ ".png": "image/png",
44
+ ".jpg": "image/jpeg",
45
+ ".jpeg": "image/jpeg",
46
+ ".webp": "image/webp",
47
+ }.get(suffix, "application/octet-stream")
48
+
49
+
50
+ def serve_file(handler: BaseHTTPRequestHandler, path: Path) -> None:
51
+ if not path.exists() or not path.is_file():
52
+ handler.send_error(HTTPStatus.NOT_FOUND)
53
+ return
54
+ data = path.read_bytes()
55
+ handler.send_response(HTTPStatus.OK)
56
+ handler.send_header("content-type", content_type(path))
57
+ handler.send_header("content-length", str(len(data)))
58
+ handler.end_headers()
59
+ handler.wfile.write(data)
60
+
61
+
62
+ class Handler(BaseHTTPRequestHandler):
63
+ def log_message(self, fmt: str, *args: object) -> None:
64
+ print("%s - - %s" % (self.address_string(), fmt % args), flush=True)
65
+
66
+ def do_GET(self) -> None:
67
+ parsed = urlparse(self.path)
68
+ path = parsed.path
69
+ if path == "/api/status":
70
+ return write_json(
71
+ self,
72
+ {
73
+ "ok": True,
74
+ "openai_api_key_set": bool(resolve_openai_api_key()),
75
+ "dataset": dataset_status(),
76
+ },
77
+ )
78
+ if path == "/api/persona-dataset/metadata":
79
+ try:
80
+ return write_json(self, dataset_metadata())
81
+ except PersonaDatasetError as exc:
82
+ return write_json(self, {"available": False, "error": str(exc), "status": dataset_status()})
83
+ if path in {"/", "/silicon", "/silicon/"}:
84
+ return serve_file(self, STATIC_DIR / "index.html")
85
+ target = (STATIC_DIR / path.lstrip("/")).resolve()
86
+ if STATIC_DIR.resolve() in target.parents or target == STATIC_DIR.resolve():
87
+ return serve_file(self, target)
88
+ self.send_error(HTTPStatus.NOT_FOUND)
89
+
90
+ def do_POST(self) -> None:
91
+ parsed = urlparse(self.path)
92
+ payload = read_body(self)
93
+ if parsed.path == "/api/persona-dataset/sample":
94
+ try:
95
+ return write_json(self, sample_personas(payload))
96
+ except PersonaDatasetError as exc:
97
+ return write_json(self, {"error": str(exc), "status": dataset_status()}, HTTPStatus.BAD_REQUEST)
98
+ if parsed.path == "/api/silicon/llm-run":
99
+ try:
100
+ return write_json(self, run_silicon_llm_sampling(payload))
101
+ except ValueError as exc:
102
+ return write_json(self, {"error": str(exc)}, HTTPStatus.BAD_REQUEST)
103
+ except RuntimeError as exc:
104
+ return write_json(self, {"error": str(exc)}, HTTPStatus.BAD_REQUEST)
105
+ self.send_error(HTTPStatus.NOT_FOUND)
106
+
107
+
108
+ def main() -> int:
109
+ import argparse
110
+
111
+ parser = argparse.ArgumentParser()
112
+ parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
113
+ parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8765")))
114
+ args = parser.parse_args()
115
+ server = ThreadingHTTPServer((args.host, args.port), Handler)
116
+ print(f"Silicon Sampling Lab running at http://{args.host}:{args.port}", flush=True)
117
+ print(f"OpenAI API key available: {bool(resolve_openai_api_key())}", flush=True)
118
+ try:
119
+ server.serve_forever()
120
+ except KeyboardInterrupt:
121
+ print("\nShutting down.", flush=True)
122
+ finally:
123
+ server.server_close()
124
+ return 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
silicon_llm.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM-backed silicon sampling runtime.
2
+
3
+ Each sampled persona is treated as one respondent-agent. The agent receives all
4
+ selected survey questions in one prompt and must return one structured answer
5
+ per question. The parser and aggregation are question-driven, so Likert/open
6
+ question mixes can change without adding scenario-specific code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ GENDER_LABELS = {"male": "남성", "female": "여성", "no_response": "응답 안 함"}
21
+ AGE_LABELS = {"20s": "20대", "30s": "30대", "40s": "40대", "50s": "50대", "60plus": "60대 이상"}
22
+ PERSONA_LABELS = {
23
+ "occ_office": "사무/관리직",
24
+ "occ_service": "서비스/판매직",
25
+ "occ_professional": "전문직",
26
+ "occ_self_employed": "자영업",
27
+ "occ_student": "학생",
28
+ "occ_homemaker": "전업주부",
29
+ "occ_technical": "기술/생산직",
30
+ "occ_retired": "은퇴/무직",
31
+ "edu_high_school": "고졸 이하",
32
+ "edu_college": "전문대/대학 재학",
33
+ "edu_bachelor": "대졸",
34
+ "edu_graduate": "대학원 이상",
35
+ "housing_apartment": "아파트",
36
+ "housing_house": "단독/다가구",
37
+ "housing_officetel": "오피스텔/원룸",
38
+ "housing_other": "기타 주거",
39
+ "marital_single": "미혼",
40
+ "marital_married": "기혼",
41
+ "marital_divorced": "이혼/별거",
42
+ "marital_widowed": "사별",
43
+ "family_single": "1인 가구",
44
+ "family_couple": "부부 가구",
45
+ "family_children": "자녀 동거",
46
+ "family_extended": "확대 가족",
47
+ }
48
+ PERSONA_DIMENSIONS = {
49
+ "occupation": ["occ_office", "occ_service", "occ_professional", "occ_self_employed", "occ_student", "occ_homemaker", "occ_technical", "occ_retired"],
50
+ "education": ["edu_high_school", "edu_college", "edu_bachelor", "edu_graduate"],
51
+ "housing": ["housing_apartment", "housing_house", "housing_officetel", "housing_other"],
52
+ "marital": ["marital_single", "marital_married", "marital_divorced", "marital_widowed"],
53
+ "family": ["family_single", "family_couple", "family_children", "family_extended"],
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class OpenAILLM:
59
+ model: str = "gpt-4o-mini"
60
+ temperature: float = 0.2
61
+ timeout: float = 60.0
62
+
63
+ def complete_agent(self, *, agent: dict[str, Any], questions: list[dict[str, Any]], response_contract: dict[str, Any]) -> str:
64
+ from openai import OpenAI
65
+
66
+ api_key = resolve_openai_api_key()
67
+ if not api_key:
68
+ raise RuntimeError("OPENAI_API_KEY or OPENAI_API_KEY_FILE is required for silicon LLM sampling")
69
+ client = OpenAI(api_key=api_key)
70
+ messages = [
71
+ {
72
+ "role": "system",
73
+ "content": (
74
+ "당신은 한국 설문조사의 가상 응답자입니다. "
75
+ "주어진 persona와 인구통계 정보를 일관되게 반영해 모든 문항에 답하십시오. "
76
+ "반드시 JSON만 반환하십시오. Likert 문항은 해당 척도 범위의 정수 value를, "
77
+ "open-ended 문항은 한국어 text와 짧은 theme를 반환하십시오."
78
+ ),
79
+ },
80
+ {
81
+ "role": "user",
82
+ "content": json.dumps(
83
+ {
84
+ "agent": agent,
85
+ "questions": questions,
86
+ "required_json_contract": response_contract,
87
+ },
88
+ ensure_ascii=False,
89
+ ),
90
+ },
91
+ ]
92
+ kwargs: dict[str, Any] = {"max_completion_tokens": 1800}
93
+ if self.model.startswith("gpt-5"):
94
+ kwargs["reasoning_effort"] = "minimal"
95
+ else:
96
+ kwargs["temperature"] = self.temperature
97
+ response = client.chat.completions.create(
98
+ model=self.model,
99
+ messages=messages,
100
+ response_format={"type": "json_object"},
101
+ timeout=self.timeout,
102
+ **kwargs,
103
+ )
104
+ return response.choices[0].message.content or "{}"
105
+
106
+
107
+ def resolve_openai_api_key() -> str | None:
108
+ key = os.getenv("OPENAI_API_KEY")
109
+ if key:
110
+ return key.strip()
111
+ path = os.getenv("OPENAI_API_KEY_FILE")
112
+ if path:
113
+ try:
114
+ return Path(path).expanduser().read_text(encoding="utf-8").strip().splitlines()[0].strip()
115
+ except (OSError, IndexError):
116
+ pass
117
+ for env_path in [Path(__file__).resolve().parent / ".env", Path.cwd() / ".env"]:
118
+ try:
119
+ if not env_path.exists():
120
+ continue
121
+ for line in env_path.read_text(encoding="utf-8").splitlines():
122
+ stripped = line.strip()
123
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
124
+ continue
125
+ name, value = stripped.split("=", 1)
126
+ if name.strip() == "OPENAI_API_KEY":
127
+ return value.strip().strip('"').strip("'")
128
+ except OSError:
129
+ continue
130
+ return None
131
+
132
+
133
+ def build_silicon_llm_response_contract(questions: list[dict[str, Any]]) -> dict[str, Any]:
134
+ answer_properties: dict[str, Any] = {}
135
+ for question in questions:
136
+ question_id = str(question.get("id") or "")
137
+ if not question_id:
138
+ continue
139
+ if question.get("kind") == "open":
140
+ answer_properties[question_id] = {
141
+ "type": "object",
142
+ "properties": {
143
+ "text": {"type": "string"},
144
+ "theme": {"type": "string"},
145
+ },
146
+ "required": ["text", "theme"],
147
+ "additionalProperties": True,
148
+ }
149
+ else:
150
+ scale = int(question.get("scale") or 5)
151
+ answer_properties[question_id] = {
152
+ "type": "object",
153
+ "properties": {
154
+ "value": {"type": "integer", "minimum": 1, "maximum": scale},
155
+ "reason": {"type": "string"},
156
+ },
157
+ "required": ["value"],
158
+ "additionalProperties": True,
159
+ }
160
+ return {
161
+ "name": "silicon_agent_answers",
162
+ "schema": {
163
+ "type": "object",
164
+ "properties": {
165
+ "answers": {
166
+ "type": "object",
167
+ "properties": answer_properties,
168
+ "required": list(answer_properties),
169
+ "additionalProperties": False,
170
+ }
171
+ },
172
+ "required": ["answers"],
173
+ "additionalProperties": False,
174
+ },
175
+ }
176
+
177
+
178
+ def parse_silicon_agent_response(raw: str, questions: list[dict[str, Any]], *, respondent_id: str) -> dict[str, Any]:
179
+ payload = extract_json(raw)
180
+ answers = payload.get("answers") if isinstance(payload, dict) else None
181
+ if isinstance(answers, list):
182
+ answers = {str(item.get("questionId") or item.get("id") or ""): item for item in answers if isinstance(item, dict)}
183
+ if not isinstance(answers, dict):
184
+ answers = {}
185
+
186
+ likert_answers: list[dict[str, Any]] = []
187
+ open_answers: list[dict[str, Any]] = []
188
+ issues: list[dict[str, Any]] = []
189
+ for question in questions:
190
+ question_id = str(question.get("id") or "")
191
+ answer = answers.get(question_id)
192
+ if not isinstance(answer, dict):
193
+ issues.append({"questionId": question_id, "type": "missing_answer"})
194
+ answer = {}
195
+ if question.get("kind") == "open":
196
+ text = str(answer.get("text") or answer.get("answer") or answer.get("response") or "").strip()
197
+ theme = str(answer.get("theme") or answer.get("topic") or infer_theme(text)).strip() or "기타"
198
+ if not text:
199
+ text = "응답을 생성하지 못했습니다."
200
+ issues.append({"questionId": question_id, "type": "empty_open_text"})
201
+ open_answers.append({"respondentId": respondent_id, "questionId": question_id, "text": text, "theme": theme[:40]})
202
+ else:
203
+ scale = int(question.get("scale") or 5)
204
+ value = coerce_int(answer.get("value", answer.get("score", answer.get("rating"))), default=math.ceil(scale / 2))
205
+ value = max(1, min(scale, value))
206
+ likert_answers.append({"respondentId": respondent_id, "questionId": question_id, "value": value})
207
+ return {"likertAnswers": likert_answers, "openAnswers": open_answers, "issues": issues}
208
+
209
+
210
+ def run_silicon_llm_sampling(payload: dict[str, Any], *, llm: Any | None = None) -> dict[str, Any]:
211
+ config = dict(payload.get("config") or payload)
212
+ questions = [dict(item) for item in config.get("questions", []) if isinstance(item, dict)]
213
+ if not questions:
214
+ raise ValueError("config.questions is required")
215
+ sample_size = bounded_int(config.get("sampleSize"), 5, minimum=1, maximum=20000)
216
+ execution = payload.get("execution") if isinstance(payload.get("execution"), dict) else {}
217
+ max_agents = bounded_int(execution.get("max_agents"), int(os.getenv("SILICON_LLM_MAX_AGENTS", "3000")), minimum=1, maximum=20000)
218
+ if sample_size > max_agents:
219
+ raise ValueError(f"LLM silicon sampling requested {sample_size} agents but max_agents is {max_agents}")
220
+
221
+ respondents = build_respondents(config, sample_size)
222
+ response_contract = build_silicon_llm_response_contract(questions)
223
+ model = str(execution.get("model") or os.getenv("SILICON_LLM_MODEL", "gpt-4o-mini"))
224
+ llm = llm or OpenAILLM(model=model, timeout=float(execution.get("timeout_seconds") or 60))
225
+ likert_answers: list[dict[str, Any]] = []
226
+ open_answers: list[dict[str, Any]] = []
227
+ parse_issues: list[dict[str, Any]] = []
228
+ raw_responses: list[dict[str, str]] = []
229
+
230
+ for respondent in respondents:
231
+ raw = llm.complete_agent(agent=respondent, questions=questions, response_contract=response_contract)
232
+ raw_responses.append({"respondentId": respondent["id"], "text": raw[:4000]})
233
+ parsed = parse_silicon_agent_response(raw, questions, respondent_id=respondent["id"])
234
+ likert_answers.extend(parsed["likertAnswers"])
235
+ open_answers.extend(parsed["openAnswers"])
236
+ parse_issues.extend({"respondentId": respondent["id"], **issue} for issue in parsed["issues"])
237
+
238
+ primary_question_id = next((str(item.get("id")) for item in questions if item.get("kind") != "open"), None)
239
+ result = {
240
+ "config": config,
241
+ "respondents": respondents,
242
+ "likertAnswers": likert_answers,
243
+ "openAnswers": open_answers,
244
+ "regionStats": build_region_stats(config, respondents, likert_answers, open_answers, questions),
245
+ "questionStats": build_question_stats(questions, likert_answers),
246
+ "primaryQuestionId": primary_question_id,
247
+ "llmTrace": {
248
+ "engine": "openai_chat_completions" if isinstance(llm, OpenAILLM) else "injected_llm",
249
+ "model": model,
250
+ "calls": len(respondents),
251
+ "questionsPerCall": len(questions),
252
+ "parseIssues": parse_issues,
253
+ "rawResponses": raw_responses,
254
+ },
255
+ }
256
+ return result
257
+
258
+
259
+ def build_respondents(config: dict[str, Any], sample_size: int) -> list[dict[str, Any]]:
260
+ genders = allocation_pool(config.get("genders"), sample_size, "female")
261
+ ages = allocation_pool(config.get("ages"), sample_size, "40s")
262
+ locations = allocation_pool(config.get("locations"), sample_size, "seoul")
263
+ persona_pools = {dimension: allocation_pool([pick for pick in config.get("personaAttributes", []) if pick.get("id") in ids], sample_size, "") for dimension, ids in PERSONA_DIMENSIONS.items()}
264
+ location_options = {str(item.get("id")): item for item in config.get("locationOptions", []) if isinstance(item, dict)}
265
+ respondents: list[dict[str, Any]] = []
266
+ for index in range(sample_size):
267
+ location_id = str(locations[index] or "seoul")
268
+ location = location_options.get(location_id, {"id": location_id, "label": location_id, "parentRegion": "seoul"})
269
+ persona_attributes: dict[str, str] = {}
270
+ persona_labels: dict[str, str] = {}
271
+ for dimension, pool in persona_pools.items():
272
+ picked = str(pool[index] or "")
273
+ if not picked:
274
+ continue
275
+ persona_attributes[dimension] = picked
276
+ persona_labels[dimension] = PERSONA_LABELS.get(picked, picked)
277
+ respondents.append(
278
+ {
279
+ "id": f"R{index + 1:04d}",
280
+ "gender": str(genders[index] or "female"),
281
+ "genderLabel": GENDER_LABELS.get(str(genders[index]), str(genders[index])),
282
+ "age": str(ages[index] or "40s"),
283
+ "ageLabel": AGE_LABELS.get(str(ages[index]), str(ages[index])),
284
+ "region": str(location.get("parentRegion") or location_id),
285
+ "location": location_id,
286
+ "locationLabel": str(location.get("label") or location_id),
287
+ "personaAttributes": persona_attributes,
288
+ "personaLabels": persona_labels,
289
+ "segment": ", ".join(persona_labels.values()) or "일반 응답자",
290
+ "trust": 0.5,
291
+ "economicAnxiety": 0.5,
292
+ "participation": 0.5,
293
+ }
294
+ )
295
+ return respondents
296
+
297
+
298
+ def allocation_pool(items: Any, target_size: int, fallback: str) -> list[str]:
299
+ source = [item for item in items or [] if isinstance(item, dict) and item.get("enabled") and float_or_zero(item.get("weight")) > 0]
300
+ if not source:
301
+ return [fallback for _ in range(target_size)]
302
+ total = sum(float_or_zero(item.get("weight")) for item in source)
303
+ rows = []
304
+ for item in source:
305
+ raw = float_or_zero(item.get("weight")) / total * target_size
306
+ rows.append({"id": str(item.get("id")), "floor": math.floor(raw), "remainder": raw - math.floor(raw)})
307
+ used = sum(row["floor"] for row in rows)
308
+ for row in sorted(rows, key=lambda item: item["remainder"], reverse=True):
309
+ if used >= target_size:
310
+ break
311
+ row["floor"] += 1
312
+ used += 1
313
+ pool: list[str] = []
314
+ for row in rows:
315
+ pool.extend([row["id"]] * int(row["floor"]))
316
+ while len(pool) < target_size:
317
+ pool.append(fallback)
318
+ return pool[:target_size]
319
+
320
+
321
+ def build_question_stats(questions: list[dict[str, Any]], likert_answers: list[dict[str, Any]]) -> list[dict[str, Any]]:
322
+ stats: list[dict[str, Any]] = []
323
+ for question in questions:
324
+ question_id = str(question.get("id"))
325
+ if question.get("kind") == "open":
326
+ stats.append({"questionId": question_id, "title": question.get("title", question_id), "kind": "open"})
327
+ continue
328
+ scale = int(question.get("scale") or 5)
329
+ values = [int(answer["value"]) for answer in likert_answers if answer.get("questionId") == question_id]
330
+ distribution = [{"value": value, "count": values.count(value), "share": values.count(value) / len(values) if values else 0} for value in range(1, scale + 1)]
331
+ positive_cut = max(3, math.ceil(scale * 0.7))
332
+ stats.append(
333
+ {
334
+ "questionId": question_id,
335
+ "title": question.get("title", question_id),
336
+ "kind": "likert",
337
+ "scale": scale,
338
+ "mean": sum(values) / len(values) if values else 0,
339
+ "positiveShare": len([value for value in values if value >= positive_cut]) / len(values) if values else 0,
340
+ "distribution": distribution,
341
+ }
342
+ )
343
+ return stats
344
+
345
+
346
+ def build_region_stats(config: dict[str, Any], respondents: list[dict[str, Any]], likert_answers: list[dict[str, Any]], open_answers: list[dict[str, Any]], questions: list[dict[str, Any]]) -> list[dict[str, Any]]:
347
+ primary = next((question for question in questions if question.get("kind") != "open"), None)
348
+ primary_id = str(primary.get("id")) if primary else None
349
+ scale = int(primary.get("scale") or 5) if primary else 5
350
+ positive_cut = max(3, math.ceil(scale * 0.7))
351
+ stats: list[dict[str, Any]] = []
352
+ for pick in config.get("locations", []):
353
+ if not isinstance(pick, dict) or not pick.get("enabled"):
354
+ continue
355
+ location_id = str(pick.get("id"))
356
+ people = [respondent for respondent in respondents if respondent.get("location") == location_id]
357
+ ids = {respondent["id"] for respondent in people}
358
+ values = [int(answer["value"]) for answer in likert_answers if answer.get("questionId") == primary_id and answer.get("respondentId") in ids]
359
+ label = next((str(item.get("label")) for item in config.get("locationOptions", []) if isinstance(item, dict) and str(item.get("id")) == location_id), location_id)
360
+ parent = next((str(item.get("parentRegion")) for item in config.get("locationOptions", []) if isinstance(item, dict) and str(item.get("id")) == location_id), location_id)
361
+ stats.append(
362
+ {
363
+ "region": location_id,
364
+ "parentRegion": parent,
365
+ "label": label,
366
+ "respondents": len(people),
367
+ "mean": sum(values) / len(values) if values else 0,
368
+ "scale": scale,
369
+ "positiveShare": len([value for value in values if value >= positive_cut]) / len(values) if values else 0,
370
+ "openCount": len([answer for answer in open_answers if answer.get("respondentId") in ids]),
371
+ }
372
+ )
373
+ return stats
374
+
375
+
376
+ def extract_json(text: str) -> Any:
377
+ text = (text or "").strip()
378
+ if not text:
379
+ raise ValueError("empty model response")
380
+ try:
381
+ return json.loads(text)
382
+ except json.JSONDecodeError:
383
+ pass
384
+ fenced = re.search(r"```(?:json)?\s*(.*?)```", text, flags=re.DOTALL | re.IGNORECASE)
385
+ if fenced:
386
+ return json.loads(fenced.group(1).strip())
387
+ start = text.find("{")
388
+ end = text.rfind("}")
389
+ if start != -1 and end != -1 and end > start:
390
+ return json.loads(text[start : end + 1])
391
+ raise ValueError(f"could not extract JSON from model response: {text[:200]}")
392
+
393
+
394
+ def infer_theme(text: str) -> str:
395
+ for token in ["물가", "의료", "교통", "주거", "돌봄", "일자리", "교육", "환경", "경제"]:
396
+ if token in text:
397
+ return token
398
+ return "기타"
399
+
400
+
401
+ def coerce_int(value: Any, *, default: int) -> int:
402
+ try:
403
+ return int(round(float(value)))
404
+ except (TypeError, ValueError):
405
+ return default
406
+
407
+
408
+ def bounded_int(value: Any, default: int, *, minimum: int, maximum: int) -> int:
409
+ try:
410
+ parsed = int(float(value))
411
+ except (TypeError, ValueError):
412
+ parsed = default
413
+ return max(minimum, min(maximum, parsed))
414
+
415
+
416
+ def float_or_zero(value: Any) -> float:
417
+ try:
418
+ return float(value)
419
+ except (TypeError, ValueError):
420
+ return 0.0
tsconfig.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["frontend/src", "vite.config.ts"],
20
+ "references": []
21
+ }
vite.config.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ root: ".",
6
+ base: "/",
7
+ plugins: [react()],
8
+ server: {
9
+ host: "127.0.0.1",
10
+ port: 5190,
11
+ strictPort: true,
12
+ proxy: {
13
+ "/api": "http://127.0.0.1:8765",
14
+ },
15
+ },
16
+ build: {
17
+ outDir: "static/immersive",
18
+ emptyOutDir: true,
19
+ manifest: true,
20
+ chunkSizeWarningLimit: 700,
21
+ rolldownOptions: {
22
+ output: {
23
+ codeSplitting: {
24
+ groups: [
25
+ { name: "vendor-react", test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/ },
26
+ { name: "vendor-echarts", test: /[\\/]node_modules[\\/]echarts[\\/]/ },
27
+ { name: "vendor-zrender", test: /[\\/]node_modules[\\/]zrender[\\/]/ },
28
+ ],
29
+ },
30
+ },
31
+ },
32
+ },
33
+ });