Spaces:
Sleeping
Sleeping
Initial silicon sampling lab deploy
Browse files- .gitignore +10 -0
- Dockerfile +23 -0
- README.md +47 -4
- frontend/src/main.tsx +10 -0
- frontend/src/silicon/SiliconCharts.tsx +339 -0
- frontend/src/silicon/SiliconSamplingApp.tsx +1043 -0
- frontend/src/silicon/data.ts +203 -0
- frontend/src/silicon/simulate.ts +430 -0
- frontend/src/silicon/types.ts +139 -0
- frontend/src/styles.css +1298 -0
- frontend/src/vite-env.d.ts +1 -0
- index.html +12 -0
- package-lock.json +1034 -0
- package.json +25 -0
- persona_dataset.py +838 -0
- requirements.txt +2 -0
- scripts/build_nemotron_persona_index.py +25 -0
- scripts/download_nemotron_personas_korea.py +38 -0
- scripts/test_silicon_llm_runtime.py +127 -0
- scripts/validate_silicon_llm_api.py +174 -0
- server.py +128 -0
- silicon_llm.py +420 -0
- tsconfig.json +21 -0
- vite.config.ts +33 -0
.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:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
});
|