kyu823 commited on
Commit
f84a7bc
·
verified ·
1 Parent(s): 71dfb74

Use HF dataset logs and improve survey outputs

Browse files
.env.example CHANGED
@@ -1,5 +1,6 @@
1
  OPENAI_API_KEY=
2
  SILICON_LLM_MODEL=gpt-4o-mini
3
  SILICON_LLM_MAX_AGENTS=3000
4
- GOOGLE_SERVICE_ACCOUNT_JSON=
5
- GOOGLE_DRIVE_FOLDER_ID=
 
 
1
  OPENAI_API_KEY=
2
  SILICON_LLM_MODEL=gpt-4o-mini
3
  SILICON_LLM_MAX_AGENTS=3000
4
+ HF_LOG_REPO_ID=kyu823/silicon-sampling-logs
5
+ HF_LOG_TOKEN=
6
+ HF_LOG_REPO_PRIVATE=true
README.md CHANGED
@@ -37,9 +37,26 @@ http://127.0.0.1:8765/silicon/
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
  ## Logs
45
 
@@ -49,14 +66,15 @@ Every completed silicon-sampling run is logged anonymously. By default logs are
49
  - `runs/silicon_logs.sqlite3` locally
50
  - `/app/runs/silicon_logs.sqlite3` on a default Hugging Face Docker Space without persistent storage
51
 
52
- Google Drive mirroring is optional. To enable it, create a Google Cloud service account with Drive API access, share one Drive folder with the service account email as an editor, then set these Space secrets:
53
 
54
  ```text
55
- GOOGLE_SERVICE_ACCOUNT_JSON=<raw service account JSON or base64-encoded JSON>
56
- GOOGLE_DRIVE_FOLDER_ID=<target Drive folder ID>
 
57
  ```
58
 
59
- When enabled, each run also writes one JSON file to that Drive folder. The JSON includes respondent settings, question definitions, and aggregate result summaries; raw LLM responses are not mirrored.
60
 
61
  ## Optional Nemotron Dataset
62
 
 
37
 
38
  ## Hugging Face Spaces
39
 
40
+ This repo is configured as a Docker Space. For Hugging Face Spaces, set secrets in the Space dashboard under Settings → Secrets, not in a committed file.
41
 
42
+ Required secrets:
43
+
44
+ - `OPENAI_API_KEY`: your OpenAI API key. This is required for the LLM-backed simulation runs.
45
+
46
+ Optional secrets:
47
+
48
+ - `HF_LOG_REPO_ID`: Hugging Face Dataset repo for run logs. Defaults to `kyu823/silicon-sampling-logs`.
49
+ - `HF_LOG_TOKEN`: Hugging Face token with write access to the log dataset repo. If omitted, the backend falls back to `HF_TOKEN`.
50
+ - `HF_LOG_REPO_PRIVATE`: set to `true` to create/use a private log dataset repo.
51
+
52
+ Deployment flow:
53
+
54
+ 1. Create or connect a Docker Space for this repository.
55
+ 2. Add the above secrets in the Space settings.
56
+ 3. Deploy the Space.
57
+ 4. The app listens on port `7860`.
58
+
59
+ The backend reads these values from environment variables, so Space secrets are the correct way to provide them. Without `OPENAI_API_KEY`, the app can still start, but the simulation run will fail when the LLM call is attempted.
60
 
61
  ## Logs
62
 
 
66
  - `runs/silicon_logs.sqlite3` locally
67
  - `/app/runs/silicon_logs.sqlite3` on a default Hugging Face Docker Space without persistent storage
68
 
69
+ Hugging Face Dataset mirroring is optional and recommended for deployed runs. To enable it, set:
70
 
71
  ```text
72
+ HF_LOG_REPO_ID=kyu823/silicon-sampling-logs
73
+ HF_LOG_TOKEN=<write token>
74
+ HF_LOG_REPO_PRIVATE=true
75
  ```
76
 
77
+ When enabled, each run also writes one JSON file to the dataset repo under `runs/YYYY-MM-DD/`. The JSON includes respondent settings, question definitions, and aggregate result summaries; raw LLM responses are not mirrored.
78
 
79
  ## Optional Nemotron Dataset
80
 
frontend/src/silicon/SiliconCharts.tsx CHANGED
@@ -5,7 +5,7 @@ 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
 
@@ -36,9 +36,10 @@ export function SiliconCharts({ result }: SiliconChartsProps) {
36
  const selectedStat = result.questionStats.find((stat) => stat.questionId === selectedQuestion?.id);
37
  const isLikert = selectedQuestion?.kind === "likert";
38
  const isCategorical = selectedQuestion?.kind === "categorical";
 
39
  const breakdownRows = useMemo(
40
- () => isLikert && breakdown !== "overall" ? resultBreakdown(result, selectedQuestion.id, breakdown) : [],
41
- [breakdown, isLikert, result, selectedQuestion?.id],
42
  );
43
 
44
  useEffect(() => {
@@ -76,7 +77,7 @@ export function SiliconCharts({ result }: SiliconChartsProps) {
76
  ].filter(Boolean).join(" ")}
77
  onClick={() => {
78
  setSelectedQuestionId(question.id);
79
- if (question.kind !== "likert") setBreakdown("overall");
80
  }}
81
  >
82
  <span>{question.kind === "likert" ? `${question.scale}점 Likert` : question.kind === "categorical" ? "Categorical" : "Open-ended"}</span>
@@ -87,69 +88,75 @@ export function SiliconCharts({ result }: SiliconChartsProps) {
87
 
88
  {isLikert ? (
89
  <>
90
- <div className="metric-tabs">
91
- <button type="button" className={metricMode === "mean" ? "active" : ""} onClick={() => setMetricMode("mean")}>평균</button>
92
- <button type="button" className={metricMode === "positive" ? "active" : ""} onClick={() => setMetricMode("positive")}>긍정률</button>
93
- </div>
 
 
94
  <div className="silicon-chart-grid single">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  {breakdown === "overall" ? (
96
- <ChartCard
97
- title="응답 분포"
98
- subtitle={selectedQuestion.title}
99
- option={{
100
- color: ["#5b6ee1"],
101
- tooltip: tooltip(),
102
- grid: grid(),
103
- xAxis: { type: "category", data: selectedStat?.distribution?.map((item) => `${item.value}`) || [], axisLabel: axisLabel() },
104
- yAxis: { type: "value", axisLabel: axisLabel(), splitLine: splitLine() },
105
- series: [{
106
- type: "bar",
107
- data: selectedStat?.distribution?.map((item) => item.count) || [],
108
- barWidth: "52%",
109
- itemStyle: { borderRadius: [8, 8, 0, 0] },
110
- }],
111
- }}
112
- />
113
- ) : (
114
- <ChartCard
115
- title={`${modeLabel(breakdown)} ${metricMode === "mean" ? "평균" : "긍정률"}`}
116
- subtitle={selectedQuestion.title}
117
- option={breakdownOption(breakdownRows, metricMode)}
118
  />
119
- )}
120
- <MetricPanel
121
- mean={selectedStat?.mean}
122
- positiveShare={selectedStat?.positiveShare}
123
- count={result.likertAnswers.filter((answer) => answer.questionId === selectedQuestion.id).length}
124
- scale={selectedQuestion.scale || 5}
125
- />
126
  <ResponseTable result={result} questionId={selectedQuestion.id} />
127
  </div>
128
  </>
129
  ) : isCategorical ? (
130
  <div className="silicon-chart-grid single">
131
- <ChartCard
132
- title="선택지별 응답 분포"
133
- subtitle={selectedQuestion.title}
134
- option={{
135
- color: ["#6f7deb"],
136
- tooltip: tooltip((value) => `${Number(value).toLocaleString()}개`),
137
- grid: { ...grid(), left: 80 },
138
- xAxis: { type: "value", axisLabel: axisLabel(), splitLine: splitLine() },
139
- yAxis: { type: "category", data: selectedStat?.distribution?.map((item) => item.label || item.optionId || "") || [], axisLabel: axisLabel() },
140
- series: [{
141
- type: "bar",
142
- data: selectedStat?.distribution?.map((item) => item.count) || [],
143
- barWidth: "54%",
144
- itemStyle: { borderRadius: [0, 8, 8, 0] },
145
- }],
146
- }}
147
- />
148
- <CategoricalMetricPanel
149
- count={result.categoricalAnswers.filter((answer) => answer.questionId === selectedQuestion.id).length}
150
- options={selectedQuestion.options?.length || 0}
151
- topLabel={topCategoricalLabel(selectedStat?.distribution)}
152
- />
 
 
 
 
 
 
 
 
 
 
153
  <ResponseTable result={result} questionId={selectedQuestion.id} />
154
  </div>
155
  ) : (
@@ -318,7 +325,7 @@ function topCategoricalLabel(distribution?: Array<{ label?: string; optionId?: s
318
  return `${top.label || top.optionId || "-"} (${top.count.toLocaleString()}개)`;
319
  }
320
 
321
- function breakdownOption(rows: ReturnType<typeof resultBreakdown>, metricMode: MetricMode) {
322
  const isPositive = metricMode === "positive";
323
  return {
324
  color: [isPositive ? "#f08a6c" : "#5b6ee1"],
@@ -340,6 +347,40 @@ function breakdownOption(rows: ReturnType<typeof resultBreakdown>, metricMode: M
340
  };
341
  }
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  function tooltip(formatter?: (value: unknown) => string) {
344
  return {
345
  trigger: "item",
 
5
  import { CanvasRenderer } from "echarts/renderers";
6
  import { AGE_BANDS, GENDERS } from "./data";
7
  import { resultBreakdown } from "./simulate";
8
+ import type { PersonaDimensionId, SiliconResult, SurveyQuestion, SyntheticRespondent } from "./types";
9
 
10
  use([BarChart, GridComponent, TooltipComponent, CanvasRenderer]);
11
 
 
36
  const selectedStat = result.questionStats.find((stat) => stat.questionId === selectedQuestion?.id);
37
  const isLikert = selectedQuestion?.kind === "likert";
38
  const isCategorical = selectedQuestion?.kind === "categorical";
39
+ const showBreakdown = breakdown !== "overall" && (isLikert || isCategorical);
40
  const breakdownRows = useMemo(
41
+ () => (showBreakdown ? resultBreakdown(result, selectedQuestion.id, breakdown) : []),
42
+ [breakdown, result, selectedQuestion?.id, showBreakdown],
43
  );
44
 
45
  useEffect(() => {
 
77
  ].filter(Boolean).join(" ")}
78
  onClick={() => {
79
  setSelectedQuestionId(question.id);
80
+ if (question.kind === "open") setBreakdown("overall");
81
  }}
82
  >
83
  <span>{question.kind === "likert" ? `${question.scale}점 Likert` : question.kind === "categorical" ? "Categorical" : "Open-ended"}</span>
 
88
 
89
  {isLikert ? (
90
  <>
91
+ {breakdown !== "overall" ? (
92
+ <div className="metric-tabs">
93
+ <button type="button" className={metricMode === "mean" ? "active" : ""} onClick={() => setMetricMode("mean")}>평균</button>
94
+ <button type="button" className={metricMode === "positive" ? "active" : ""} onClick={() => setMetricMode("positive")}>긍정률</button>
95
+ </div>
96
+ ) : null}
97
  <div className="silicon-chart-grid single">
98
+ <ChartCard
99
+ title={breakdown === "overall" ? "응답 분포" : `${modeLabel(breakdown)} ${metricMode === "mean" ? "평균" : "긍정률"}`}
100
+ subtitle={selectedQuestion.title}
101
+ option={breakdown === "overall" ? {
102
+ color: ["#5b6ee1"],
103
+ tooltip: tooltip(),
104
+ grid: grid(),
105
+ xAxis: { type: "category", data: selectedStat?.distribution?.map((item) => `${item.value}`) || [], axisLabel: axisLabel() },
106
+ yAxis: { type: "value", axisLabel: axisLabel(), splitLine: splitLine() },
107
+ series: [{
108
+ type: "bar",
109
+ data: selectedStat?.distribution?.map((item) => item.count) || [],
110
+ barWidth: "52%",
111
+ itemStyle: { borderRadius: [8, 8, 0, 0] },
112
+ }],
113
+ } : likertBreakdownOption(breakdownRows, metricMode)}
114
+ />
115
  {breakdown === "overall" ? (
116
+ <MetricPanel
117
+ mean={selectedStat?.mean}
118
+ positiveShare={selectedStat?.positiveShare}
119
+ count={result.likertAnswers.filter((answer) => answer.questionId === selectedQuestion.id).length}
120
+ scale={selectedQuestion.scale || 5}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  />
122
+ ) : null}
 
 
 
 
 
 
123
  <ResponseTable result={result} questionId={selectedQuestion.id} />
124
  </div>
125
  </>
126
  ) : isCategorical ? (
127
  <div className="silicon-chart-grid single">
128
+ {breakdown === "overall" ? (
129
+ <ChartCard
130
+ title="선택지별 응답 분포"
131
+ subtitle={selectedQuestion.title}
132
+ option={{
133
+ color: ["#6f7deb"],
134
+ tooltip: tooltip((value) => `${Number(value).toLocaleString()}개`),
135
+ grid: { ...grid(), left: 80 },
136
+ xAxis: { type: "value", axisLabel: axisLabel(), splitLine: splitLine() },
137
+ yAxis: { type: "category", data: selectedStat?.distribution?.map((item) => item.label || item.optionId || "") || [], axisLabel: axisLabel() },
138
+ series: [{
139
+ type: "bar",
140
+ data: selectedStat?.distribution?.map((item) => item.count) || [],
141
+ barWidth: "54%",
142
+ itemStyle: { borderRadius: [0, 8, 8, 0] },
143
+ }],
144
+ }}
145
+ />
146
+ ) : (
147
+ <ChartCard
148
+ title="집단별 응답 분포"
149
+ subtitle={selectedQuestion.title}
150
+ option={categoricalBreakdownOption(breakdownRows, selectedQuestion)}
151
+ />
152
+ )}
153
+ {breakdown === "overall" ? (
154
+ <CategoricalMetricPanel
155
+ count={result.categoricalAnswers.filter((answer) => answer.questionId === selectedQuestion.id).length}
156
+ options={selectedQuestion.options?.length || 0}
157
+ topLabel={topCategoricalLabel(selectedStat?.distribution)}
158
+ />
159
+ ) : null}
160
  <ResponseTable result={result} questionId={selectedQuestion.id} />
161
  </div>
162
  ) : (
 
325
  return `${top.label || top.optionId || "-"} (${top.count.toLocaleString()}개)`;
326
  }
327
 
328
+ function likertBreakdownOption(rows: ReturnType<typeof resultBreakdown>, metricMode: MetricMode) {
329
  const isPositive = metricMode === "positive";
330
  return {
331
  color: [isPositive ? "#f08a6c" : "#5b6ee1"],
 
347
  };
348
  }
349
 
350
+ function categoricalBreakdownOption(rows: ReturnType<typeof resultBreakdown>, question: SurveyQuestion) {
351
+ const buckets = (question.options || []).map((option) => ({ id: option.id, label: option.label }));
352
+ const colors = ["#5b6ee1", "#4db7c0", "#f0b56c", "#f08a6c", "#8d7cf3", "#53b56a", "#7e8bd8"];
353
+ return {
354
+ tooltip: {
355
+ trigger: "axis",
356
+ backgroundColor: "rgba(255, 255, 255, .96)",
357
+ borderColor: "rgba(84, 96, 137, .18)",
358
+ textStyle: { color: "#18213a" },
359
+ axisPointer: { type: "shadow" },
360
+ },
361
+ legend: {
362
+ data: buckets.map((bucket) => bucket.label),
363
+ top: 0,
364
+ textStyle: { color: "rgba(24, 33, 58, .7)", fontSize: 11 },
365
+ },
366
+ grid: { ...grid(), left: 92, right: 18, top: 46, bottom: 24 },
367
+ xAxis: {
368
+ type: "value",
369
+ axisLabel: axisLabel(),
370
+ splitLine: splitLine(),
371
+ },
372
+ yAxis: { type: "category", data: rows.map((item) => item.label), axisLabel: axisLabel() },
373
+ series: buckets.map((bucket, index) => ({
374
+ name: bucket.label,
375
+ type: "bar",
376
+ stack: "total",
377
+ data: rows.map((row) => row.distribution.find((item) => item.id === bucket.id)?.count || 0),
378
+ barWidth: "54%",
379
+ itemStyle: { color: colors[index % colors.length], borderRadius: [0, 0, 0, 0] },
380
+ })),
381
+ };
382
+ }
383
+
384
  function tooltip(formatter?: (value: unknown) => string) {
385
  return {
386
  trigger: "item",
frontend/src/silicon/SiliconSamplingApp.tsx CHANGED
@@ -52,6 +52,7 @@ const FALLBACK_NEMOTRON_COLUMNS = [
52
  type CountRow = { value: string; count: number };
53
  type FlowStage = "respondents" | "questions" | "results";
54
  type FilterValue<T extends string> = typeof ALL_FILTER | T;
 
55
  type NemotronMetadata = {
56
  columns?: string[];
57
  sex_counts?: CountRow[];
@@ -92,6 +93,8 @@ export function SiliconSamplingApp() {
92
  const [result, setResult] = useState<SiliconResult | null>(null);
93
  const [isRunning, setIsRunning] = useState(false);
94
  const [runStatus, setRunStatus] = useState("응답자 조건을 확인하고 응답자를 생성하세요.");
 
 
95
 
96
  const sidoOptions = useMemo(() => {
97
  const options = locationOptions.filter((location) => location.level === "sido");
@@ -157,16 +160,46 @@ export function SiliconSamplingApp() {
157
  if (!generatedRespondents.length) return;
158
  setGeneratedRespondents([]);
159
  setResult(null);
 
160
  setRunStatus("응답자 조건이 바뀌었습니다. 응답자를 다시 생성하세요.");
161
  }, [sampleSize, genderFilter, ageFilter, regionFilter]); // eslint-disable-line react-hooks/exhaustive-deps
162
 
 
 
 
 
 
163
  const generateRespondents = () => {
164
  const preview = simulateSiliconSampling({ ...config, questions: [] }).respondents;
165
  setGeneratedRespondents(preview);
166
  setResult(null);
 
167
  setRunStatus(`${preview.length.toLocaleString()}명의 응답자 프로필을 생성했습니다. 목록을 확인한 뒤 문항 설정으로 이동하세요.`);
168
  };
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  const run = async () => {
171
  if (!canRun) {
172
  setRunStatus(!generatedRespondents.length ? "먼저 응답자를 생성하세요." : "시뮬레이션 실행 전 문항을 하나 이상 선택하세요.");
@@ -214,6 +247,8 @@ export function SiliconSamplingApp() {
214
  setQuestionBankOpen(true);
215
  setGeneratedRespondents([]);
216
  setResult(null);
 
 
217
  setIsRunning(false);
218
  setRunStatus("초기화했습니다. 기본 비율 조건으로 응답자를 생성하세요.");
219
  };
@@ -239,6 +274,7 @@ export function SiliconSamplingApp() {
239
  setCustomLowLabel("");
240
  setCustomHighLabel("");
241
  setCustomCategoricalOptions(DEFAULT_CATEGORICAL_OPTIONS);
 
242
  };
243
 
244
  useEffect(() => {
@@ -326,6 +362,9 @@ export function SiliconSamplingApp() {
326
  customCategoricalOptions={customCategoricalOptions}
327
  setCustomCategoricalOptions={setCustomCategoricalOptions}
328
  questions={questions}
 
 
 
329
  onAddCustomQuestion={addCustomQuestion}
330
  onBack={() => setStage("respondents")}
331
  onRun={run}
@@ -472,6 +511,9 @@ function QuestionSetup({
472
  customCategoricalOptions,
473
  setCustomCategoricalOptions,
474
  questions,
 
 
 
475
  onAddCustomQuestion,
476
  onBack,
477
  onRun,
@@ -496,11 +538,22 @@ function QuestionSetup({
496
  customCategoricalOptions: string[];
497
  setCustomCategoricalOptions: (value: string[] | ((current: string[]) => string[])) => void;
498
  questions: SurveyQuestion[];
 
 
 
499
  onAddCustomQuestion: () => void;
500
  onBack: () => void;
501
  onRun: () => void;
502
  canRun: boolean;
503
  }) {
 
 
 
 
 
 
 
 
504
  const removeQuestion = (question: SurveyQuestion) => {
505
  if (question.id.startsWith("custom_")) {
506
  setCustomQuestions((items) => items.filter((item) => item.id !== question.id));
@@ -614,6 +667,35 @@ function QuestionSetup({
614
  </div>
615
  </section>
616
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  <section className="final-question-card" data-testid="silicon-final-question-list">
618
  <header>
619
  <div>
 
52
  type CountRow = { value: string; count: number };
53
  type FlowStage = "respondents" | "questions" | "results";
54
  type FilterValue<T extends string> = typeof ALL_FILTER | T;
55
+ type PromptPreview = { systemContent: string; userContent: string; sampleRespondent?: unknown; questionCount?: number };
56
  type NemotronMetadata = {
57
  columns?: string[];
58
  sex_counts?: CountRow[];
 
93
  const [result, setResult] = useState<SiliconResult | null>(null);
94
  const [isRunning, setIsRunning] = useState(false);
95
  const [runStatus, setRunStatus] = useState("응답자 조건을 확인하고 응답자를 생성하세요.");
96
+ const [promptPreview, setPromptPreview] = useState<PromptPreview | null>(null);
97
+ const [promptPreviewStatus, setPromptPreviewStatus] = useState("");
98
 
99
  const sidoOptions = useMemo(() => {
100
  const options = locationOptions.filter((location) => location.level === "sido");
 
160
  if (!generatedRespondents.length) return;
161
  setGeneratedRespondents([]);
162
  setResult(null);
163
+ setPromptPreview(null);
164
  setRunStatus("응답자 조건이 바뀌었습니다. 응답자를 다시 생성하세요.");
165
  }, [sampleSize, genderFilter, ageFilter, regionFilter]); // eslint-disable-line react-hooks/exhaustive-deps
166
 
167
+ useEffect(() => {
168
+ setPromptPreview(null);
169
+ setPromptPreviewStatus("");
170
+ }, [questions]);
171
+
172
  const generateRespondents = () => {
173
  const preview = simulateSiliconSampling({ ...config, questions: [] }).respondents;
174
  setGeneratedRespondents(preview);
175
  setResult(null);
176
+ setPromptPreview(null);
177
  setRunStatus(`${preview.length.toLocaleString()}명의 응답자 프로필을 생성했습니다. 목록을 확인한 뒤 문항 설정으로 이동하세요.`);
178
  };
179
 
180
+ const loadPromptPreview = async () => {
181
+ if (!questions.length) {
182
+ setPromptPreview(null);
183
+ setPromptPreviewStatus("문항을 하나 이상 선택하면 실행 프롬프트를 확인할 수 있습니다.");
184
+ return;
185
+ }
186
+ setPromptPreviewStatus("실제 백엔드 프롬프트를 불러오는 중입니다.");
187
+ try {
188
+ const response = await fetch("/api/silicon/prompt-preview", {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ body: JSON.stringify({ config: { ...config, respondents: generatedRespondents } }),
192
+ });
193
+ const payload = await response.json();
194
+ if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`);
195
+ setPromptPreview(payload as PromptPreview);
196
+ setPromptPreviewStatus("실제 실행에 사용되는 백엔드 프롬프트입니다.");
197
+ } catch (error) {
198
+ setPromptPreview(null);
199
+ setPromptPreviewStatus(`프롬프트를 불러오지 못했습니다: ${error instanceof Error ? error.message : String(error)}`);
200
+ }
201
+ };
202
+
203
  const run = async () => {
204
  if (!canRun) {
205
  setRunStatus(!generatedRespondents.length ? "먼저 응답자를 생성하세요." : "시뮬레이션 실행 전 문항을 하나 이상 선택하세요.");
 
247
  setQuestionBankOpen(true);
248
  setGeneratedRespondents([]);
249
  setResult(null);
250
+ setPromptPreview(null);
251
+ setPromptPreviewStatus("");
252
  setIsRunning(false);
253
  setRunStatus("초기화했습니다. 기본 비율 조건으로 응답자를 생성하세요.");
254
  };
 
274
  setCustomLowLabel("");
275
  setCustomHighLabel("");
276
  setCustomCategoricalOptions(DEFAULT_CATEGORICAL_OPTIONS);
277
+ setPromptPreview(null);
278
  };
279
 
280
  useEffect(() => {
 
362
  customCategoricalOptions={customCategoricalOptions}
363
  setCustomCategoricalOptions={setCustomCategoricalOptions}
364
  questions={questions}
365
+ promptPreview={promptPreview}
366
+ promptPreviewStatus={promptPreviewStatus}
367
+ onLoadPromptPreview={loadPromptPreview}
368
  onAddCustomQuestion={addCustomQuestion}
369
  onBack={() => setStage("respondents")}
370
  onRun={run}
 
511
  customCategoricalOptions,
512
  setCustomCategoricalOptions,
513
  questions,
514
+ promptPreview,
515
+ promptPreviewStatus,
516
+ onLoadPromptPreview,
517
  onAddCustomQuestion,
518
  onBack,
519
  onRun,
 
538
  customCategoricalOptions: string[];
539
  setCustomCategoricalOptions: (value: string[] | ((current: string[]) => string[])) => void;
540
  questions: SurveyQuestion[];
541
+ promptPreview: PromptPreview | null;
542
+ promptPreviewStatus: string;
543
+ onLoadPromptPreview: () => void;
544
  onAddCustomQuestion: () => void;
545
  onBack: () => void;
546
  onRun: () => void;
547
  canRun: boolean;
548
  }) {
549
+ const [promptPreviewOpen, setPromptPreviewOpen] = useState(false);
550
+ const togglePromptPreview = () => {
551
+ setPromptPreviewOpen((value) => {
552
+ const next = !value;
553
+ if (next) onLoadPromptPreview();
554
+ return next;
555
+ });
556
+ };
557
  const removeQuestion = (question: SurveyQuestion) => {
558
  if (question.id.startsWith("custom_")) {
559
  setCustomQuestions((items) => items.filter((item) => item.id !== question.id));
 
667
  </div>
668
  </section>
669
 
670
+ <section className="prompt-preview-card">
671
+ <button
672
+ type="button"
673
+ className={promptPreviewOpen ? "question-bank-toggle open" : "question-bank-toggle"}
674
+ aria-expanded={promptPreviewOpen}
675
+ onClick={togglePromptPreview}
676
+ >
677
+ <ChevronDown size={15} />
678
+ 실행 전 프롬프트 구성 미리보기 {promptPreviewOpen ? "접기" : "열기"}
679
+ </button>
680
+ {promptPreviewOpen ? (
681
+ <div className="prompt-preview-body">
682
+ {promptPreviewStatus ? <p className="prompt-preview-status">{promptPreviewStatus}</p> : null}
683
+ {promptPreview ? (
684
+ <>
685
+ <div className="prompt-preview-block">
686
+ <span>system</span>
687
+ <pre>{promptPreview.systemContent}</pre>
688
+ </div>
689
+ <div className="prompt-preview-block">
690
+ <span>user</span>
691
+ <pre>{promptPreview.userContent}</pre>
692
+ </div>
693
+ </>
694
+ ) : null}
695
+ </div>
696
+ ) : null}
697
+ </section>
698
+
699
  <section className="final-question-card" data-testid="silicon-final-question-list">
700
  <header>
701
  <div>
frontend/src/silicon/simulate.ts CHANGED
@@ -294,9 +294,17 @@ export function groupBreakdown(result: SiliconResult, dimension: "gender" | "age
294
 
295
  export function resultBreakdown(result: SiliconResult, questionId: string, dimension: "gender" | "age" | "region" | PersonaDimensionId) {
296
  const question = result.config.questions.find((item) => item.id === questionId);
297
- if (!question || question.kind !== "likert") return [];
298
- const answers = result.likertAnswers.filter((answer) => answer.questionId === questionId);
299
- const valuesByRespondent = new Map(answers.map((answer) => [answer.respondentId, answer.value]));
 
 
 
 
 
 
 
 
300
  const scale = question.scale || 5;
301
  const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
302
  const options = dimension === "gender"
@@ -315,13 +323,25 @@ export function resultBreakdown(result: SiliconResult, questionId: string, dimen
315
  if (dimension === "region") return respondent.location === id;
316
  return respondent.personaAttributes[dimension] === id;
317
  });
318
- const values = people.map((respondent) => valuesByRespondent.get(respondent.id)).filter((value): value is number => typeof value === "number");
 
 
 
 
 
 
 
 
 
 
 
319
  return {
320
  id,
321
  label,
322
  respondents: people.length,
323
- mean: mean(values),
324
- positiveShare: values.length ? values.filter((value) => value >= positiveCut).length / values.length : 0,
 
325
  };
326
  }).filter((item) => item.respondents > 0);
327
  }
 
294
 
295
  export function resultBreakdown(result: SiliconResult, questionId: string, dimension: "gender" | "age" | "region" | PersonaDimensionId) {
296
  const question = result.config.questions.find((item) => item.id === questionId);
297
+ if (!question || (question.kind !== "likert" && question.kind !== "categorical")) return [];
298
+ const valuesByRespondent = new Map<string, number | string>();
299
+ if (question.kind === "likert") {
300
+ for (const answer of result.likertAnswers.filter((item) => item.questionId === questionId)) {
301
+ valuesByRespondent.set(answer.respondentId, answer.value);
302
+ }
303
+ } else {
304
+ for (const answer of result.categoricalAnswers.filter((item) => item.questionId === questionId)) {
305
+ valuesByRespondent.set(answer.respondentId, answer.optionId);
306
+ }
307
+ }
308
  const scale = question.scale || 5;
309
  const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
310
  const options = dimension === "gender"
 
323
  if (dimension === "region") return respondent.location === id;
324
  return respondent.personaAttributes[dimension] === id;
325
  });
326
+ const values = people.map((respondent) => valuesByRespondent.get(respondent.id)).filter((value): value is number | string => value !== undefined);
327
+ const numericValues = values.filter((value): value is number => typeof value === "number");
328
+ const distribution = question.kind === "likert"
329
+ ? Array.from({ length: scale }, (_, index) => {
330
+ const value = index + 1;
331
+ const count = values.filter((item) => item === value).length;
332
+ return { id: String(value), label: `${value}점`, count, share: values.length ? count / values.length : 0 };
333
+ })
334
+ : (question.options || []).map((questionOption) => {
335
+ const count = values.filter((item) => item === questionOption.id).length;
336
+ return { id: questionOption.id, label: questionOption.label, count, share: values.length ? count / values.length : 0 };
337
+ });
338
  return {
339
  id,
340
  label,
341
  respondents: people.length,
342
+ mean: mean(numericValues),
343
+ positiveShare: numericValues.length ? numericValues.filter((value) => value >= positiveCut).length / numericValues.length : 0,
344
+ distribution,
345
  };
346
  }).filter((item) => item.respondents > 0);
347
  }
frontend/src/styles.css CHANGED
@@ -1533,6 +1533,60 @@ button {
1533
  margin-top: 14px;
1534
  }
1535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1536
  .respondent-preview-card header,
1537
  .final-question-card header {
1538
  display: flex;
 
1533
  margin-top: 14px;
1534
  }
1535
 
1536
+ .prompt-preview-card {
1537
+ margin-top: 14px;
1538
+ border: 1px solid rgba(84, 96, 137, 0.14);
1539
+ border-radius: 22px;
1540
+ padding: 18px;
1541
+ background: rgba(255, 255, 255, 0.92);
1542
+ box-shadow: 0 18px 52px rgba(39, 55, 96, 0.09);
1543
+ }
1544
+
1545
+ .prompt-preview-card .question-bank-toggle {
1546
+ width: 100%;
1547
+ justify-content: flex-start;
1548
+ margin-left: 0;
1549
+ }
1550
+
1551
+ .prompt-preview-body {
1552
+ display: grid;
1553
+ gap: 12px;
1554
+ margin-top: 12px;
1555
+ }
1556
+
1557
+ .prompt-preview-status {
1558
+ margin: 0;
1559
+ color: rgba(24, 33, 58, 0.62);
1560
+ font-size: 13px;
1561
+ line-height: 1.5;
1562
+ }
1563
+
1564
+ .prompt-preview-block {
1565
+ border: 1px solid rgba(84, 96, 137, 0.13);
1566
+ border-radius: 16px;
1567
+ padding: 12px;
1568
+ background: #fbfcff;
1569
+ }
1570
+
1571
+ .prompt-preview-block span {
1572
+ display: block;
1573
+ color: rgba(24, 33, 58, 0.58);
1574
+ font-size: 11px;
1575
+ font-weight: 850;
1576
+ letter-spacing: 0.07em;
1577
+ text-transform: uppercase;
1578
+ }
1579
+
1580
+ .prompt-preview-block pre {
1581
+ margin: 8px 0 0;
1582
+ white-space: pre-wrap;
1583
+ word-break: break-word;
1584
+ font-family: Consolas, "Courier New", monospace;
1585
+ font-size: 12px;
1586
+ line-height: 1.6;
1587
+ color: #18213a;
1588
+ }
1589
+
1590
  .respondent-preview-card header,
1591
  .final-question-card header {
1592
  display: flex;
hf_dataset_logs.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ from typing import Any
7
+
8
+
9
+ DEFAULT_LOG_REPO_ID = "kyu823/silicon-sampling-logs"
10
+
11
+
12
+ def hf_log_status() -> dict[str, Any]:
13
+ repo_id = hf_log_repo_id()
14
+ token = hf_log_token()
15
+ return {
16
+ "enabled": bool(repo_id and token),
17
+ "repoId": repo_id,
18
+ "tokenSet": bool(token),
19
+ "private": hf_log_private(),
20
+ }
21
+
22
+
23
+ def upload_run_log_to_hf_dataset(run_document: dict[str, Any]) -> dict[str, Any]:
24
+ repo_id = hf_log_repo_id()
25
+ token = hf_log_token()
26
+ if not repo_id:
27
+ raise RuntimeError("HF_LOG_REPO_ID is required for Hugging Face Dataset logging")
28
+ if not token:
29
+ raise RuntimeError("HF_LOG_TOKEN or HF_TOKEN is required for Hugging Face Dataset logging")
30
+
31
+ from huggingface_hub import HfApi
32
+
33
+ api = HfApi(token=token)
34
+ api.create_repo(repo_id, repo_type="dataset", private=hf_log_private(), exist_ok=True)
35
+ path_in_repo = hf_log_path(run_document)
36
+ data = json.dumps(run_document, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
37
+ commit = api.upload_file(
38
+ path_or_fileobj=io.BytesIO(data),
39
+ path_in_repo=path_in_repo,
40
+ repo_id=repo_id,
41
+ repo_type="dataset",
42
+ commit_message=f"Add silicon sampling log {run_document.get('runId', 'run')}",
43
+ )
44
+ return {
45
+ "enabled": True,
46
+ "uploaded": True,
47
+ "repoId": repo_id,
48
+ "path": path_in_repo,
49
+ "commitUrl": getattr(commit, "commit_url", None),
50
+ }
51
+
52
+
53
+ def hf_log_repo_id() -> str:
54
+ return os.getenv("HF_LOG_REPO_ID", DEFAULT_LOG_REPO_ID).strip()
55
+
56
+
57
+ def hf_log_token() -> str:
58
+ return (os.getenv("HF_LOG_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
59
+
60
+
61
+ def hf_log_private() -> bool:
62
+ return os.getenv("HF_LOG_REPO_PRIVATE", "true").strip().lower() not in {"0", "false", "no"}
63
+
64
+
65
+ def hf_log_path(run_document: dict[str, Any]) -> str:
66
+ created = str(run_document.get("createdAt") or "")
67
+ day = created[:10] if len(created) >= 10 else "unknown-date"
68
+ safe_created = created.replace(":", "-").replace("+", "Z")
69
+ run_id = str(run_document.get("runId") or "run")
70
+ return f"runs/{day}/{safe_created}_{run_id}.json"
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
  openai>=1.0.0
2
  duckdb>=1.0.0
3
- google-api-python-client>=2.0.0
4
- google-auth>=2.0.0
 
1
  openai>=1.0.0
2
  duckdb>=1.0.0
3
+ huggingface_hub>=0.34.0
 
scripts/test_silicon_llm_runtime.py CHANGED
@@ -14,6 +14,8 @@ 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,
@@ -86,6 +88,27 @@ class SiliconLLMRuntimeTests(unittest.TestCase):
86
  self.assertIn("rationale", answer_properties["q_open"]["required"])
87
  self.assertEqual(answer_properties["q_categorical"]["properties"]["optionId"]["enum"], ["money", "honor", "health"])
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def test_parser_extracts_all_dynamic_answers_from_fenced_json(self) -> None:
90
  raw = """
91
  생각 과정은 생략합니다.
 
14
  sys.path.insert(0, str(ROOT))
15
 
16
  from silicon_llm import ( # noqa: E402
17
+ build_silicon_llm_system_prompt,
18
+ build_silicon_prompt_preview,
19
  build_silicon_llm_response_contract,
20
  parse_silicon_agent_response,
21
  run_silicon_llm_sampling,
 
88
  self.assertIn("rationale", answer_properties["q_open"]["required"])
89
  self.assertEqual(answer_properties["q_categorical"]["properties"]["optionId"]["enum"], ["money", "honor", "health"])
90
 
91
+ def test_prompt_preview_uses_personalized_backend_prompt(self) -> None:
92
+ system_prompt = build_silicon_llm_system_prompt()
93
+ self.assertIn("1인칭", system_prompt)
94
+ self.assertIn("최소 두 가지", system_prompt)
95
+ self.assertIn("일반론", system_prompt)
96
+ preview = build_silicon_prompt_preview(
97
+ {
98
+ "config": {
99
+ "sampleSize": 1,
100
+ "genders": [{"id": "female", "enabled": True, "weight": 1}],
101
+ "ages": [{"id": "40s", "enabled": True, "weight": 1}],
102
+ "locations": [{"id": "seoul", "enabled": True, "weight": 1}],
103
+ "locationOptions": [{"id": "seoul", "label": "서울", "parentRegion": "seoul", "level": "sido"}],
104
+ "questions": QUESTIONS,
105
+ }
106
+ }
107
+ )
108
+ self.assertEqual(preview["systemContent"], system_prompt)
109
+ self.assertIn("required_json_contract", preview["userContent"])
110
+ self.assertIn("q_categorical", preview["userContent"])
111
+
112
  def test_parser_extracts_all_dynamic_answers_from_fenced_json(self) -> None:
113
  raw = """
114
  생각 과정은 생략합니다.
scripts/test_silicon_logs.py CHANGED
@@ -8,14 +8,13 @@ import sqlite3
8
  import sys
9
  import tempfile
10
  import unittest
11
- import base64
12
  from pathlib import Path
13
 
14
 
15
  ROOT = Path(__file__).resolve().parents[1]
16
  sys.path.insert(0, str(ROOT))
17
 
18
- from google_drive_logs import drive_log_filename, parse_service_account_json # noqa: E402
19
  from silicon_logs import log_status, record_run, recent_runs # noqa: E402
20
 
21
 
@@ -24,7 +23,11 @@ class SiliconLogTests(unittest.TestCase):
24
  with tempfile.TemporaryDirectory() as tmp:
25
  path = Path(tmp) / "logs.sqlite3"
26
  old = os.environ.get("SILICON_LOG_DB")
 
 
27
  os.environ["SILICON_LOG_DB"] = str(path)
 
 
28
  try:
29
  info = record_run(
30
  {
@@ -54,10 +57,10 @@ class SiliconLogTests(unittest.TestCase):
54
  client={"userAgent": "unit-test"},
55
  )
56
  self.assertTrue(info["runId"].startswith("run_"))
57
- self.assertEqual(info["googleDrive"], {"enabled": False, "uploaded": False})
58
  self.assertTrue(path.exists())
59
  self.assertEqual(log_status()["runCount"], 1)
60
- self.assertIn("googleDrive", log_status())
61
  rows = recent_runs()
62
  self.assertEqual(len(rows), 1)
63
  self.assertEqual(rows[0]["sampleSize"], 2)
@@ -71,8 +74,12 @@ class SiliconLogTests(unittest.TestCase):
71
  os.environ.pop("SILICON_LOG_DB", None)
72
  else:
73
  os.environ["SILICON_LOG_DB"] = old
 
 
 
 
74
 
75
- def test_record_run_can_mirror_json_document_to_google_drive_uploader(self) -> None:
76
  with tempfile.TemporaryDirectory() as tmp:
77
  path = Path(tmp) / "logs.sqlite3"
78
  old = os.environ.get("SILICON_LOG_DB")
@@ -81,7 +88,7 @@ class SiliconLogTests(unittest.TestCase):
81
 
82
  def fake_uploader(document: dict) -> dict:
83
  uploaded.append(document)
84
- return {"enabled": True, "uploaded": True, "fileId": "drive_file_123", "name": drive_log_filename(document)}
85
 
86
  try:
87
  info = record_run(
@@ -104,9 +111,10 @@ class SiliconLogTests(unittest.TestCase):
104
  "regionStats": [],
105
  "llmTrace": {"calls": 1, "rawResponses": ["not mirrored"]},
106
  },
107
- drive_uploader=fake_uploader,
108
  )
109
- self.assertEqual(info["googleDrive"]["fileId"], "drive_file_123")
 
110
  self.assertEqual(len(uploaded), 1)
111
  self.assertEqual(uploaded[0]["questions"][0]["kind"], "categorical")
112
  self.assertEqual(uploaded[0]["respondentSettings"]["locations"][0]["label"], "부산")
@@ -117,12 +125,6 @@ class SiliconLogTests(unittest.TestCase):
117
  else:
118
  os.environ["SILICON_LOG_DB"] = old
119
 
120
- def test_service_account_json_can_be_plain_or_base64(self) -> None:
121
- raw = '{"type":"service_account","client_email":"bot@example.iam.gserviceaccount.com"}'
122
- self.assertEqual(parse_service_account_json(raw)["client_email"], "bot@example.iam.gserviceaccount.com")
123
- encoded = base64.b64encode(raw.encode("utf-8")).decode("utf-8")
124
- self.assertEqual(parse_service_account_json(encoded)["client_email"], "bot@example.iam.gserviceaccount.com")
125
-
126
 
127
  if __name__ == "__main__":
128
  unittest.main(verbosity=2)
 
8
  import sys
9
  import tempfile
10
  import unittest
 
11
  from pathlib import Path
12
 
13
 
14
  ROOT = Path(__file__).resolve().parents[1]
15
  sys.path.insert(0, str(ROOT))
16
 
17
+ from hf_dataset_logs import hf_log_path # noqa: E402
18
  from silicon_logs import log_status, record_run, recent_runs # noqa: E402
19
 
20
 
 
23
  with tempfile.TemporaryDirectory() as tmp:
24
  path = Path(tmp) / "logs.sqlite3"
25
  old = os.environ.get("SILICON_LOG_DB")
26
+ old_hf_token = os.environ.get("HF_TOKEN")
27
+ old_hf_log_token = os.environ.get("HF_LOG_TOKEN")
28
  os.environ["SILICON_LOG_DB"] = str(path)
29
+ os.environ.pop("HF_TOKEN", None)
30
+ os.environ.pop("HF_LOG_TOKEN", None)
31
  try:
32
  info = record_run(
33
  {
 
57
  client={"userAgent": "unit-test"},
58
  )
59
  self.assertTrue(info["runId"].startswith("run_"))
60
+ self.assertEqual(info["hfDataset"], {"enabled": False, "uploaded": False})
61
  self.assertTrue(path.exists())
62
  self.assertEqual(log_status()["runCount"], 1)
63
+ self.assertIn("hfDataset", log_status())
64
  rows = recent_runs()
65
  self.assertEqual(len(rows), 1)
66
  self.assertEqual(rows[0]["sampleSize"], 2)
 
74
  os.environ.pop("SILICON_LOG_DB", None)
75
  else:
76
  os.environ["SILICON_LOG_DB"] = old
77
+ if old_hf_token is not None:
78
+ os.environ["HF_TOKEN"] = old_hf_token
79
+ if old_hf_log_token is not None:
80
+ os.environ["HF_LOG_TOKEN"] = old_hf_log_token
81
 
82
+ def test_record_run_can_mirror_json_document_to_hf_dataset_uploader(self) -> None:
83
  with tempfile.TemporaryDirectory() as tmp:
84
  path = Path(tmp) / "logs.sqlite3"
85
  old = os.environ.get("SILICON_LOG_DB")
 
88
 
89
  def fake_uploader(document: dict) -> dict:
90
  uploaded.append(document)
91
+ return {"enabled": True, "uploaded": True, "repoId": "kyu823/silicon-sampling-logs", "path": hf_log_path(document)}
92
 
93
  try:
94
  info = record_run(
 
111
  "regionStats": [],
112
  "llmTrace": {"calls": 1, "rawResponses": ["not mirrored"]},
113
  },
114
+ hf_uploader=fake_uploader,
115
  )
116
+ self.assertEqual(info["hfDataset"]["repoId"], "kyu823/silicon-sampling-logs")
117
+ self.assertTrue(info["hfDataset"]["path"].startswith("runs/"))
118
  self.assertEqual(len(uploaded), 1)
119
  self.assertEqual(uploaded[0]["questions"][0]["kind"], "categorical")
120
  self.assertEqual(uploaded[0]["respondentSettings"]["locations"][0]["label"], "부산")
 
125
  else:
126
  os.environ["SILICON_LOG_DB"] = old
127
 
 
 
 
 
 
 
128
 
129
  if __name__ == "__main__":
130
  unittest.main(verbosity=2)
server.py CHANGED
@@ -9,7 +9,7 @@ from urllib.parse import parse_qs, urlparse
9
 
10
  from persona_dataset import PersonaDatasetError, dataset_metadata, dataset_status, sample_personas
11
  from silicon_logs import log_status, recent_runs, record_run
12
- from silicon_llm import resolve_openai_api_key, run_silicon_llm_sampling
13
 
14
 
15
  APP_DIR = Path(__file__).resolve().parent
@@ -105,6 +105,11 @@ class Handler(BaseHTTPRequestHandler):
105
  return write_json(self, sample_personas(payload))
106
  except PersonaDatasetError as exc:
107
  return write_json(self, {"error": str(exc), "status": dataset_status()}, HTTPStatus.BAD_REQUEST)
 
 
 
 
 
108
  if parsed.path == "/api/silicon/llm-run":
109
  try:
110
  result = run_silicon_llm_sampling(payload)
 
9
 
10
  from persona_dataset import PersonaDatasetError, dataset_metadata, dataset_status, sample_personas
11
  from silicon_logs import log_status, recent_runs, record_run
12
+ from silicon_llm import build_silicon_prompt_preview, resolve_openai_api_key, run_silicon_llm_sampling
13
 
14
 
15
  APP_DIR = Path(__file__).resolve().parent
 
105
  return write_json(self, sample_personas(payload))
106
  except PersonaDatasetError as exc:
107
  return write_json(self, {"error": str(exc), "status": dataset_status()}, HTTPStatus.BAD_REQUEST)
108
+ if parsed.path == "/api/silicon/prompt-preview":
109
+ try:
110
+ return write_json(self, build_silicon_prompt_preview(payload))
111
+ except ValueError as exc:
112
+ return write_json(self, {"error": str(exc)}, HTTPStatus.BAD_REQUEST)
113
  if parsed.path == "/api/silicon/llm-run":
114
  try:
115
  result = run_silicon_llm_sampling(payload)
silicon_llm.py CHANGED
@@ -54,6 +54,39 @@ PERSONA_DIMENSIONS = {
54
  }
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @dataclass
58
  class OpenAILLM:
59
  model: str = "gpt-4o-mini"
@@ -67,30 +100,7 @@ class OpenAILLM:
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
- "categorical 문항은 제공된 options 중 하나의 optionId와 label을, "
78
- "open-ended 문항은 한국어 text와 짧은 theme를 반환하십시오. "
79
- "모든 답변에는 왜 그렇게 답했는지 한 문장의 rationale을 포함하십시오."
80
- ),
81
- },
82
- {
83
- "role": "user",
84
- "content": json.dumps(
85
- {
86
- "agent": agent,
87
- "questions": questions,
88
- "required_json_contract": response_contract,
89
- },
90
- ensure_ascii=False,
91
- ),
92
- },
93
- ]
94
  kwargs: dict[str, Any] = {"max_completion_tokens": 1800}
95
  if self.model.startswith("gpt-5"):
96
  kwargs["reasoning_effort"] = "minimal"
@@ -300,6 +310,21 @@ def run_silicon_llm_sampling(payload: dict[str, Any], *, llm: Any | None = None)
300
  return result
301
 
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  def build_respondents(config: dict[str, Any], sample_size: int) -> list[dict[str, Any]]:
304
  genders = allocation_pool(config.get("genders"), sample_size, "female")
305
  ages = allocation_pool(config.get("ages"), sample_size, "40s")
 
54
  }
55
 
56
 
57
+ def build_silicon_llm_system_prompt() -> str:
58
+ return (
59
+ "당신은 한국 설문조사의 가상 응답자입니다. "
60
+ "주어진 persona와 인구통계 정보를 일관되게 반영해 모든 문항에 답하십시오. "
61
+ "반드시 JSON만 반환하십시오. Likert 문항은 해당 척도 범위의 정수 value를, "
62
+ "categorical 문항은 제공된 options 중 하나의 optionId와 label을, "
63
+ "open-ended 문항은 한국어 text와 짧은 theme를 반환하십시오. "
64
+ "모든 답변에는 rationale을 포함하십시오. rationale은 응답자의 1인칭 관점으로 쓰고, "
65
+ "성별, 연령, 지역, 직업, 학력, 주거, 혼인, 가구 등 제공된 배경 중 최소 두 가지를 구체적으로 언급하십시오. "
66
+ "rationale은 '나는 ... 배경/상황이라서 ...라고 판단했다'처럼 개인 맥락이 보이게 작성하고, "
67
+ "문항을 반복하거나 일반론만 말하지 마십시오."
68
+ )
69
+
70
+
71
+ def build_silicon_llm_user_prompt(agent: dict[str, Any], questions: list[dict[str, Any]], response_contract: dict[str, Any], *, pretty: bool = False) -> str:
72
+ return json.dumps(
73
+ {
74
+ "agent": agent,
75
+ "questions": questions,
76
+ "required_json_contract": response_contract,
77
+ },
78
+ ensure_ascii=False,
79
+ indent=2 if pretty else None,
80
+ )
81
+
82
+
83
+ def build_silicon_llm_messages(agent: dict[str, Any], questions: list[dict[str, Any]], response_contract: dict[str, Any]) -> list[dict[str, str]]:
84
+ return [
85
+ {"role": "system", "content": build_silicon_llm_system_prompt()},
86
+ {"role": "user", "content": build_silicon_llm_user_prompt(agent, questions, response_contract)},
87
+ ]
88
+
89
+
90
  @dataclass
91
  class OpenAILLM:
92
  model: str = "gpt-4o-mini"
 
100
  if not api_key:
101
  raise RuntimeError("OPENAI_API_KEY or OPENAI_API_KEY_FILE is required for silicon LLM sampling")
102
  client = OpenAI(api_key=api_key)
103
+ messages = build_silicon_llm_messages(agent, questions, response_contract)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  kwargs: dict[str, Any] = {"max_completion_tokens": 1800}
105
  if self.model.startswith("gpt-5"):
106
  kwargs["reasoning_effort"] = "minimal"
 
310
  return result
311
 
312
 
313
+ def build_silicon_prompt_preview(payload: dict[str, Any]) -> dict[str, Any]:
314
+ config = dict(payload.get("config") or payload)
315
+ questions = [dict(item) for item in config.get("questions", []) if isinstance(item, dict)]
316
+ provided_respondents = config.get("respondents") if isinstance(config.get("respondents"), list) else []
317
+ respondents = provided_respondents if provided_respondents else build_respondents(config, 1)
318
+ agent = dict(respondents[0]) if respondents else build_respondents(config, 1)[0]
319
+ response_contract = build_silicon_llm_response_contract(questions)
320
+ return {
321
+ "systemContent": build_silicon_llm_system_prompt(),
322
+ "userContent": build_silicon_llm_user_prompt(agent, questions, response_contract, pretty=True),
323
+ "sampleRespondent": agent,
324
+ "questionCount": len(questions),
325
+ }
326
+
327
+
328
  def build_respondents(config: dict[str, Any], sample_size: int) -> list[dict[str, Any]]:
329
  genders = allocation_pool(config.get("genders"), sample_size, "female")
330
  ages = allocation_pool(config.get("ages"), sample_size, "40s")
silicon_logs.py CHANGED
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
8
  from pathlib import Path
9
  from typing import Any, Callable
10
 
11
- from google_drive_logs import drive_log_status, upload_run_log_to_drive
12
 
13
 
14
  APP_DIR = Path(__file__).resolve().parent
@@ -32,7 +32,7 @@ def log_status() -> dict[str, Any]:
32
  "exists": path.exists(),
33
  "storage": "persistent_bucket" if str(path).startswith("/data/") else "local_ephemeral",
34
  "runCount": count_runs(path) if path.exists() else 0,
35
- "googleDrive": drive_log_status(),
36
  }
37
 
38
 
@@ -40,7 +40,7 @@ def record_run(
40
  payload: dict[str, Any],
41
  result: dict[str, Any],
42
  client: dict[str, Any] | None = None,
43
- drive_uploader: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
44
  ) -> dict[str, Any]:
45
  path = default_log_path()
46
  ensure_schema(path)
@@ -78,14 +78,14 @@ def record_run(
78
  ),
79
  )
80
  info = {"runId": run_id, "createdAt": created_at, "storage": log_status()["storage"], "path": str(path)}
81
- uploader = drive_uploader or upload_run_log_to_drive
82
- if drive_uploader or drive_log_status()["enabled"]:
83
  try:
84
- info["googleDrive"] = uploader(run_document)
85
  except Exception as exc: # noqa: BLE001
86
- info["googleDrive"] = {"enabled": True, "uploaded": False, "error": f"{type(exc).__name__}: {exc}"}
87
  else:
88
- info["googleDrive"] = {"enabled": False, "uploaded": False}
89
  return info
90
 
91
 
 
8
  from pathlib import Path
9
  from typing import Any, Callable
10
 
11
+ from hf_dataset_logs import hf_log_status, upload_run_log_to_hf_dataset
12
 
13
 
14
  APP_DIR = Path(__file__).resolve().parent
 
32
  "exists": path.exists(),
33
  "storage": "persistent_bucket" if str(path).startswith("/data/") else "local_ephemeral",
34
  "runCount": count_runs(path) if path.exists() else 0,
35
+ "hfDataset": hf_log_status(),
36
  }
37
 
38
 
 
40
  payload: dict[str, Any],
41
  result: dict[str, Any],
42
  client: dict[str, Any] | None = None,
43
+ hf_uploader: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
44
  ) -> dict[str, Any]:
45
  path = default_log_path()
46
  ensure_schema(path)
 
78
  ),
79
  )
80
  info = {"runId": run_id, "createdAt": created_at, "storage": log_status()["storage"], "path": str(path)}
81
+ uploader = hf_uploader or upload_run_log_to_hf_dataset
82
+ if hf_uploader or hf_log_status()["enabled"]:
83
  try:
84
+ info["hfDataset"] = uploader(run_document)
85
  except Exception as exc: # noqa: BLE001
86
+ info["hfDataset"] = {"enabled": True, "uploaded": False, "error": f"{type(exc).__name__}: {exc}"}
87
  else:
88
+ info["hfDataset"] = {"enabled": False, "uploaded": False}
89
  return info
90
 
91