kyu823 commited on
Commit
d33bb4f
·
verified ·
1 Parent(s): fcb0023

Add categorical survey questions and run logging

Browse files
frontend/src/silicon/SiliconCharts.tsx CHANGED
@@ -35,6 +35,7 @@ export function SiliconCharts({ result }: SiliconChartsProps) {
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],
@@ -71,14 +72,14 @@ export function SiliconCharts({ result }: SiliconChartsProps) {
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
  ))}
@@ -125,6 +126,32 @@ export function SiliconCharts({ result }: SiliconChartsProps) {
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} />
@@ -135,6 +162,22 @@ export function SiliconCharts({ result }: SiliconChartsProps) {
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">
@@ -204,15 +247,17 @@ function ResponseTable({ result, questionId }: { result: SiliconResult; question
204
  const question = result.config.questions.find((item) => item.id === questionId);
205
  if (!question) return null;
206
  const likertByRespondent = new Map(result.likertAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.value]));
 
207
  const openByRespondent = new Map(result.openAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.text]));
208
  const rationaleByRespondent = new Map([
209
  ...result.likertAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.rationale || ""] as const),
 
210
  ...result.openAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.rationale || ""] as const),
211
  ]);
212
  const rows = result.respondents
213
  .map((respondent) => ({
214
  respondent,
215
- value: question.kind === "likert" ? likertByRespondent.get(respondent.id) : openByRespondent.get(respondent.id),
216
  rationale: rationaleByRespondent.get(respondent.id) || "",
217
  }))
218
  .filter((row) => row.value !== undefined);
@@ -233,7 +278,7 @@ function ResponseTable({ result, questionId }: { result: SiliconResult; question
233
  <th>주거</th>
234
  <th>혼인</th>
235
  <th>가구</th>
236
- <th>{question.kind === "likert" ? "점수" : "답변"}</th>
237
  <th>응답 근거</th>
238
  </tr>
239
  </thead>
@@ -267,6 +312,12 @@ function MetricItem({ label, value }: { label: string; value: string }) {
267
  );
268
  }
269
 
 
 
 
 
 
 
270
  function breakdownOption(rows: ReturnType<typeof resultBreakdown>, metricMode: MetricMode) {
271
  const isPositive = metricMode === "positive";
272
  return {
 
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 isCategorical = selectedQuestion?.kind === "categorical";
39
  const breakdownRows = useMemo(
40
  () => isLikert && breakdown !== "overall" ? resultBreakdown(result, selectedQuestion.id, breakdown) : [],
41
  [breakdown, isLikert, result, selectedQuestion?.id],
 
72
  type="button"
73
  className={[
74
  selectedQuestion.id === question.id ? "active" : "",
75
+ question.kind === "likert" ? "kind-likert" : question.kind === "categorical" ? "kind-categorical" : "kind-open",
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>
83
  <strong>{question.title}</strong>
84
  </button>
85
  ))}
 
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
  ) : (
156
  <div className="silicon-chart-grid open-only">
157
  <OpenAnswerPanel result={result} questionId={selectedQuestion.id} />
 
162
  );
163
  }
164
 
165
+ function CategoricalMetricPanel({ count, options, topLabel }: { count: number; options: number; topLabel: string }) {
166
+ return (
167
+ <section className="silicon-chart-card metric-card">
168
+ <header>
169
+ <span>선택 문항</span>
170
+ <strong>요약 지표</strong>
171
+ </header>
172
+ <div className="metric-card-grid">
173
+ <MetricItem label="응답 수" value={`${count.toLocaleString()}개`} />
174
+ <MetricItem label="선택지 수" value={`${options.toLocaleString()}개`} />
175
+ <MetricItem label="최다 선택" value={topLabel} />
176
+ </div>
177
+ </section>
178
+ );
179
+ }
180
+
181
  function MetricPanel({ mean, positiveShare, count, scale }: { mean?: number; positiveShare?: number; count: number; scale: number }) {
182
  return (
183
  <section className="silicon-chart-card metric-card">
 
247
  const question = result.config.questions.find((item) => item.id === questionId);
248
  if (!question) return null;
249
  const likertByRespondent = new Map(result.likertAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.value]));
250
+ const categoricalByRespondent = new Map(result.categoricalAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.label]));
251
  const openByRespondent = new Map(result.openAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.text]));
252
  const rationaleByRespondent = new Map([
253
  ...result.likertAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.rationale || ""] as const),
254
+ ...result.categoricalAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.rationale || ""] as const),
255
  ...result.openAnswers.filter((answer) => answer.questionId === questionId).map((answer) => [answer.respondentId, answer.rationale || ""] as const),
256
  ]);
257
  const rows = result.respondents
258
  .map((respondent) => ({
259
  respondent,
260
+ value: question.kind === "likert" ? likertByRespondent.get(respondent.id) : question.kind === "categorical" ? categoricalByRespondent.get(respondent.id) : openByRespondent.get(respondent.id),
261
  rationale: rationaleByRespondent.get(respondent.id) || "",
262
  }))
263
  .filter((row) => row.value !== undefined);
 
278
  <th>주거</th>
279
  <th>혼인</th>
280
  <th>가구</th>
281
+ <th>{question.kind === "likert" ? "점수" : question.kind === "categorical" ? "선택" : "답변"}</th>
282
  <th>응답 근거</th>
283
  </tr>
284
  </thead>
 
312
  );
313
  }
314
 
315
+ function topCategoricalLabel(distribution?: Array<{ label?: string; optionId?: string; count: number }>) {
316
+ const top = [...(distribution || [])].sort((a, b) => b.count - a.count)[0];
317
+ if (!top) return "-";
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 {
frontend/src/silicon/SiliconSamplingApp.tsx CHANGED
@@ -17,6 +17,7 @@ declare global {
17
  const SAMPLE_PRESETS = [10, 20, 50, 100];
18
  const MIN_SAMPLE_SIZE = 1;
19
  const MAX_SAMPLE_SIZE = 100;
 
20
  const VISIBLE_GENDERS = GENDERS.filter((gender) => gender.id !== "no_response");
21
  const SIDO_LOCATION_OPTIONS = LOCATION_OPTIONS.filter((location) => location.level === "sido");
22
  const ALL_FILTER = "all";
@@ -84,6 +85,7 @@ export function SiliconSamplingApp() {
84
  const [customScale, setCustomScale] = useState<4 | 5 | 7>(5);
85
  const [customLowLabel, setCustomLowLabel] = useState("");
86
  const [customHighLabel, setCustomHighLabel] = useState("");
 
87
  const [questionBankOpen, setQuestionBankOpen] = useState(true);
88
  const [generatedRespondents, setGeneratedRespondents] = useState<SyntheticRespondent[]>([]);
89
  const [seed, setSeed] = useState(20260629);
@@ -208,6 +210,7 @@ export function SiliconSamplingApp() {
208
  setCustomScale(5);
209
  setCustomLowLabel("");
210
  setCustomHighLabel("");
 
211
  setQuestionBankOpen(true);
212
  setGeneratedRespondents([]);
213
  setResult(null);
@@ -218,20 +221,24 @@ export function SiliconSamplingApp() {
218
  const addCustomQuestion = () => {
219
  const title = customTitle.trim();
220
  if (!title) return;
 
 
221
  const question: SurveyQuestion = {
222
  id: `custom_${Date.now().toString(36)}`,
223
  title,
224
  source: "사용자 추가 문항",
225
- category: customKind === "likert" ? "직접 입력 Likert" : "직접 입력 자유응답",
226
  kind: customKind,
227
  scale: customKind === "likert" ? customScale : undefined,
228
  lowLabel: customKind === "likert" ? (customLowLabel.trim() || "낮음") : undefined,
229
  highLabel: customKind === "likert" ? (customHighLabel.trim() || "높음") : undefined,
 
230
  };
231
  setCustomQuestions((items) => [...items, question]);
232
  setCustomTitle("");
233
  setCustomLowLabel("");
234
  setCustomHighLabel("");
 
235
  };
236
 
237
  useEffect(() => {
@@ -247,6 +254,7 @@ export function SiliconSamplingApp() {
247
  questionCount: questions.length,
248
  likertCount: questions.filter((question) => question.kind === "likert").length,
249
  openCount: questions.filter((question) => question.kind === "open").length,
 
250
  hasResult: Boolean(result),
251
  isRunning,
252
  resultOnlyPage: stage === "results",
@@ -315,6 +323,8 @@ export function SiliconSamplingApp() {
315
  setCustomLowLabel={setCustomLowLabel}
316
  customHighLabel={customHighLabel}
317
  setCustomHighLabel={setCustomHighLabel}
 
 
318
  questions={questions}
319
  onAddCustomQuestion={addCustomQuestion}
320
  onBack={() => setStage("respondents")}
@@ -459,6 +469,8 @@ function QuestionSetup({
459
  setCustomLowLabel,
460
  customHighLabel,
461
  setCustomHighLabel,
 
 
462
  questions,
463
  onAddCustomQuestion,
464
  onBack,
@@ -481,6 +493,8 @@ function QuestionSetup({
481
  setCustomLowLabel: (value: string) => void;
482
  customHighLabel: string;
483
  setCustomHighLabel: (value: string) => void;
 
 
484
  questions: SurveyQuestion[];
485
  onAddCustomQuestion: () => void;
486
  onBack: () => void;
@@ -524,11 +538,11 @@ function QuestionSetup({
524
  type="button"
525
  className={[
526
  selectedQuestionIds.includes(question.id) ? "selected" : "",
527
- question.kind === "likert" ? "kind-likert" : "kind-open",
528
  ].filter(Boolean).join(" ")}
529
  onClick={() => setSelectedQuestionIds((ids) => ids.includes(question.id) ? ids.filter((id) => id !== question.id) : [...ids, question.id])}
530
  >
531
- <span>{question.category} · {question.kind === "likert" ? `${question.scale}점` : "자유응답"}</span>
532
  <strong>{question.title}</strong>
533
  <em>{question.source}</em>
534
  </button>
@@ -554,6 +568,7 @@ function QuestionSetup({
554
  <span>답변 양식</span>
555
  <select value={customKind} onChange={(event) => setCustomKind(event.target.value as QuestionKind)}>
556
  <option value="likert">Likert</option>
 
557
  <option value="open">Open-ended</option>
558
  </select>
559
  </label>
@@ -577,6 +592,24 @@ function QuestionSetup({
577
  </label>
578
  </>
579
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  <button data-testid="silicon-custom-question-add" type="button" className="primary" onClick={onAddCustomQuestion}><Plus size={15} /> 추가</button>
581
  </div>
582
  </section>
@@ -592,9 +625,9 @@ function QuestionSetup({
592
  {questions.length ? (
593
  <div className="final-question-list">
594
  {questions.map((question) => (
595
- <article key={question.id} className={question.kind === "likert" ? "kind-likert" : "kind-open"}>
596
  <div>
597
- <span>{question.kind === "likert" ? `${question.scale}점 Likert · 1점=${question.lowLabel || "낮음"} · ${question.scale}점=${question.highLabel || "높음"}` : "Open-ended"}</span>
598
  <strong>{question.title}</strong>
599
  </div>
600
  <button type="button" onClick={() => removeQuestion(question)}>제거</button>
@@ -615,6 +648,7 @@ function QuestionSetup({
615
 
616
  function ResultsOnly({ result, isRunning, runStatus, onBack }: { result: SiliconResult | null; isRunning: boolean; runStatus: string; onBack: () => void }) {
617
  const likertCount = result?.config.questions.filter((question) => question.kind === "likert").length || 0;
 
618
  const openCount = result?.config.questions.filter((question) => question.kind === "open").length || 0;
619
  return (
620
  <section className="results-only-panel">
@@ -625,6 +659,7 @@ function ResultsOnly({ result, isRunning, runStatus, onBack }: { result: Silicon
625
  </div>
626
  <div className="result-stats">
627
  <span><strong>{likertCount}</strong> Likert</span>
 
628
  <span><strong>{openCount}</strong> open-ended</span>
629
  <span><strong>{result?.config.questions.length || 0}</strong> questions</span>
630
  </div>
@@ -653,6 +688,7 @@ function ResultsOnly({ result, isRunning, runStatus, onBack }: { result: Silicon
653
  </header>
654
  <Metric label="평균 점수" value={formatNumber(result.questionStats.find((item) => item.questionId === result.primaryQuestionId)?.mean)} />
655
  <Metric label="긍정 응답률" value={formatPercent(result.questionStats.find((item) => item.questionId === result.primaryQuestionId)?.positiveShare)} />
 
656
  <Metric label="자유응답 수" value={`${result.openAnswers.length.toLocaleString()}개`} />
657
  <Metric label="문항 수" value={`${result.config.questions.length.toLocaleString()}개`} />
658
  </section>
@@ -911,6 +947,20 @@ function labelOf<T extends string>(items: Array<{ id: T; label: string }>, id?:
911
  return items.find((item) => item.id === id)?.label || id || "-";
912
  }
913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
  function clampNumber(value: string, min: number, max: number) {
915
  const number = Number(value);
916
  if (!Number.isFinite(number)) return min;
 
17
  const SAMPLE_PRESETS = [10, 20, 50, 100];
18
  const MIN_SAMPLE_SIZE = 1;
19
  const MAX_SAMPLE_SIZE = 100;
20
+ const DEFAULT_CATEGORICAL_OPTIONS = ["돈", "명예", "건강"];
21
  const VISIBLE_GENDERS = GENDERS.filter((gender) => gender.id !== "no_response");
22
  const SIDO_LOCATION_OPTIONS = LOCATION_OPTIONS.filter((location) => location.level === "sido");
23
  const ALL_FILTER = "all";
 
85
  const [customScale, setCustomScale] = useState<4 | 5 | 7>(5);
86
  const [customLowLabel, setCustomLowLabel] = useState("");
87
  const [customHighLabel, setCustomHighLabel] = useState("");
88
+ const [customCategoricalOptions, setCustomCategoricalOptions] = useState<string[]>(() => DEFAULT_CATEGORICAL_OPTIONS);
89
  const [questionBankOpen, setQuestionBankOpen] = useState(true);
90
  const [generatedRespondents, setGeneratedRespondents] = useState<SyntheticRespondent[]>([]);
91
  const [seed, setSeed] = useState(20260629);
 
210
  setCustomScale(5);
211
  setCustomLowLabel("");
212
  setCustomHighLabel("");
213
+ setCustomCategoricalOptions(DEFAULT_CATEGORICAL_OPTIONS);
214
  setQuestionBankOpen(true);
215
  setGeneratedRespondents([]);
216
  setResult(null);
 
221
  const addCustomQuestion = () => {
222
  const title = customTitle.trim();
223
  if (!title) return;
224
+ const categoricalOptions = normalizeCategoricalOptions(customCategoricalOptions);
225
+ if (customKind === "categorical" && categoricalOptions.length < 2) return;
226
  const question: SurveyQuestion = {
227
  id: `custom_${Date.now().toString(36)}`,
228
  title,
229
  source: "사용자 추가 문항",
230
+ category: customKind === "likert" ? "직접 입력 Likert" : customKind === "categorical" ? "직접 입력 Categorical" : "직접 입력 자유응답",
231
  kind: customKind,
232
  scale: customKind === "likert" ? customScale : undefined,
233
  lowLabel: customKind === "likert" ? (customLowLabel.trim() || "낮음") : undefined,
234
  highLabel: customKind === "likert" ? (customHighLabel.trim() || "높음") : undefined,
235
+ options: customKind === "categorical" ? categoricalOptions.map((label, index) => ({ id: `opt_${index + 1}`, label })) : undefined,
236
  };
237
  setCustomQuestions((items) => [...items, question]);
238
  setCustomTitle("");
239
  setCustomLowLabel("");
240
  setCustomHighLabel("");
241
+ setCustomCategoricalOptions(DEFAULT_CATEGORICAL_OPTIONS);
242
  };
243
 
244
  useEffect(() => {
 
254
  questionCount: questions.length,
255
  likertCount: questions.filter((question) => question.kind === "likert").length,
256
  openCount: questions.filter((question) => question.kind === "open").length,
257
+ categoricalCount: questions.filter((question) => question.kind === "categorical").length,
258
  hasResult: Boolean(result),
259
  isRunning,
260
  resultOnlyPage: stage === "results",
 
323
  setCustomLowLabel={setCustomLowLabel}
324
  customHighLabel={customHighLabel}
325
  setCustomHighLabel={setCustomHighLabel}
326
+ customCategoricalOptions={customCategoricalOptions}
327
+ setCustomCategoricalOptions={setCustomCategoricalOptions}
328
  questions={questions}
329
  onAddCustomQuestion={addCustomQuestion}
330
  onBack={() => setStage("respondents")}
 
469
  setCustomLowLabel,
470
  customHighLabel,
471
  setCustomHighLabel,
472
+ customCategoricalOptions,
473
+ setCustomCategoricalOptions,
474
  questions,
475
  onAddCustomQuestion,
476
  onBack,
 
493
  setCustomLowLabel: (value: string) => void;
494
  customHighLabel: string;
495
  setCustomHighLabel: (value: string) => void;
496
+ customCategoricalOptions: string[];
497
+ setCustomCategoricalOptions: (value: string[] | ((current: string[]) => string[])) => void;
498
  questions: SurveyQuestion[];
499
  onAddCustomQuestion: () => void;
500
  onBack: () => void;
 
538
  type="button"
539
  className={[
540
  selectedQuestionIds.includes(question.id) ? "selected" : "",
541
+ question.kind === "likert" ? "kind-likert" : question.kind === "categorical" ? "kind-categorical" : "kind-open",
542
  ].filter(Boolean).join(" ")}
543
  onClick={() => setSelectedQuestionIds((ids) => ids.includes(question.id) ? ids.filter((id) => id !== question.id) : [...ids, question.id])}
544
  >
545
+ <span>{question.category} · {question.kind === "likert" ? `${question.scale}점` : question.kind === "categorical" ? "선택형" : "자유응답"}</span>
546
  <strong>{question.title}</strong>
547
  <em>{question.source}</em>
548
  </button>
 
568
  <span>답변 양식</span>
569
  <select value={customKind} onChange={(event) => setCustomKind(event.target.value as QuestionKind)}>
570
  <option value="likert">Likert</option>
571
+ <option value="categorical">Categorical</option>
572
  <option value="open">Open-ended</option>
573
  </select>
574
  </label>
 
592
  </label>
593
  </>
594
  ) : null}
595
+ {customKind === "categorical" ? (
596
+ <div className="categorical-options-editor">
597
+ <div className="categorical-options-head">
598
+ <span>선택지</span>
599
+ <button type="button" onClick={() => setCustomCategoricalOptions((items) => [...items, ""])}>선택지 추가</button>
600
+ </div>
601
+ {customCategoricalOptions.map((option, index) => (
602
+ <label key={index}>
603
+ <span>{index + 1}번 선택지</span>
604
+ <input
605
+ value={option}
606
+ onChange={(event) => setCustomCategoricalOptions((items) => items.map((item, itemIndex) => itemIndex === index ? event.target.value : item))}
607
+ placeholder={`예: ${DEFAULT_CATEGORICAL_OPTIONS[index] || "기타"}`}
608
+ />
609
+ </label>
610
+ ))}
611
+ </div>
612
+ ) : null}
613
  <button data-testid="silicon-custom-question-add" type="button" className="primary" onClick={onAddCustomQuestion}><Plus size={15} /> 추가</button>
614
  </div>
615
  </section>
 
625
  {questions.length ? (
626
  <div className="final-question-list">
627
  {questions.map((question) => (
628
+ <article key={question.id} className={question.kind === "likert" ? "kind-likert" : question.kind === "categorical" ? "kind-categorical" : "kind-open"}>
629
  <div>
630
+ <span>{questionSummary(question)}</span>
631
  <strong>{question.title}</strong>
632
  </div>
633
  <button type="button" onClick={() => removeQuestion(question)}>제거</button>
 
648
 
649
  function ResultsOnly({ result, isRunning, runStatus, onBack }: { result: SiliconResult | null; isRunning: boolean; runStatus: string; onBack: () => void }) {
650
  const likertCount = result?.config.questions.filter((question) => question.kind === "likert").length || 0;
651
+ const categoricalCount = result?.config.questions.filter((question) => question.kind === "categorical").length || 0;
652
  const openCount = result?.config.questions.filter((question) => question.kind === "open").length || 0;
653
  return (
654
  <section className="results-only-panel">
 
659
  </div>
660
  <div className="result-stats">
661
  <span><strong>{likertCount}</strong> Likert</span>
662
+ <span><strong>{categoricalCount}</strong> categorical</span>
663
  <span><strong>{openCount}</strong> open-ended</span>
664
  <span><strong>{result?.config.questions.length || 0}</strong> questions</span>
665
  </div>
 
688
  </header>
689
  <Metric label="평균 점수" value={formatNumber(result.questionStats.find((item) => item.questionId === result.primaryQuestionId)?.mean)} />
690
  <Metric label="긍정 응답률" value={formatPercent(result.questionStats.find((item) => item.questionId === result.primaryQuestionId)?.positiveShare)} />
691
+ <Metric label="선택형 응답 수" value={`${(result.categoricalAnswers || []).length.toLocaleString()}개`} />
692
  <Metric label="자유응답 수" value={`${result.openAnswers.length.toLocaleString()}개`} />
693
  <Metric label="문항 수" value={`${result.config.questions.length.toLocaleString()}개`} />
694
  </section>
 
947
  return items.find((item) => item.id === id)?.label || id || "-";
948
  }
949
 
950
+ function normalizeCategoricalOptions(options: string[]) {
951
+ return options.map((option) => option.trim()).filter(Boolean);
952
+ }
953
+
954
+ function questionSummary(question: SurveyQuestion) {
955
+ if (question.kind === "likert") {
956
+ return `${question.scale}점 Likert · 1점=${question.lowLabel || "낮음"} · ${question.scale}점=${question.highLabel || "높음"}`;
957
+ }
958
+ if (question.kind === "categorical") {
959
+ return `Categorical · ${(question.options || []).map((option) => option.label).join(" / ") || "선택지 없음"}`;
960
+ }
961
+ return "Open-ended";
962
+ }
963
+
964
  function clampNumber(value: string, min: number, max: number) {
965
  const number = Number(value);
966
  if (!Number.isFinite(number)) return min;
frontend/src/silicon/simulate.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { AGE_BANDS, GENDERS, LOCATION_OPTIONS, PERSONA_OPTIONS, REGIONS } from "./data";
2
  import type {
3
  AgeBandId,
 
4
  GenderId,
5
  LikertAnswer,
6
  LocationId,
@@ -56,8 +57,10 @@ 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);
@@ -80,6 +83,13 @@ export function simulateSiliconSampling(config: SiliconConfig): SiliconResult {
80
  rationale: answerLikertRationale(question, respondent),
81
  });
82
  }
 
 
 
 
 
 
 
83
  for (const question of openQuestions) {
84
  openAnswers.push({
85
  respondentId: respondent.id,
@@ -94,9 +104,10 @@ export function simulateSiliconSampling(config: SiliconConfig): SiliconResult {
94
  config,
95
  respondents,
96
  likertAnswers,
 
97
  openAnswers,
98
  regionStats: buildRegionStats(config.locations, config.locationOptions, respondents, likertAnswers, openAnswers, likertQuestions[0]),
99
- questionStats: buildQuestionStats(config.questions, likertAnswers),
100
  primaryQuestionId,
101
  };
102
  }
@@ -187,6 +198,16 @@ function answerOpen(question: SurveyQuestion, respondent: SyntheticRespondent, r
187
  return { theme, text, rationale };
188
  }
189
 
 
 
 
 
 
 
 
 
 
 
190
  function buildRegionStats(
191
  locations: WeightedPick<LocationId>[],
192
  locationOptions: SiliconConfig["locationOptions"],
@@ -217,9 +238,23 @@ function buildRegionStats(
217
  });
218
  }
219
 
220
- function buildQuestionStats(questions: SurveyQuestion[], likertAnswers: LikertAnswer[]): QuestionStat[] {
221
  return questions.map((question) => {
222
  if (question.kind === "open") return { questionId: question.id, title: question.title, kind: "open" };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  const scale = question.scale || 5;
224
  const answers = likertAnswers.filter((answer) => answer.questionId === question.id);
225
  const distribution = Array.from({ length: scale }, (_, index) => {
 
1
  import { AGE_BANDS, GENDERS, LOCATION_OPTIONS, PERSONA_OPTIONS, REGIONS } from "./data";
2
  import type {
3
  AgeBandId,
4
+ CategoricalAnswer,
5
  GenderId,
6
  LikertAnswer,
7
  LocationId,
 
57
  const rand = mulberry32(config.seed);
58
  const respondents: SyntheticRespondent[] = [];
59
  const likertAnswers: LikertAnswer[] = [];
60
+ const categoricalAnswers: CategoricalAnswer[] = [];
61
  const openAnswers: OpenAnswer[] = [];
62
  const likertQuestions = config.questions.filter((question) => question.kind === "likert");
63
+ const categoricalQuestions = config.questions.filter((question) => question.kind === "categorical");
64
  const openQuestions = config.questions.filter((question) => question.kind === "open");
65
  const genderPool = buildAllocationPool(config.genders, config.sampleSize, "female", rand);
66
  const agePool = buildAllocationPool(config.ages, config.sampleSize, "40s", rand);
 
83
  rationale: answerLikertRationale(question, respondent),
84
  });
85
  }
86
+ for (const question of categoricalQuestions) {
87
+ categoricalAnswers.push({
88
+ respondentId: respondent.id,
89
+ questionId: question.id,
90
+ ...answerCategorical(question, respondent, rand),
91
+ });
92
+ }
93
  for (const question of openQuestions) {
94
  openAnswers.push({
95
  respondentId: respondent.id,
 
104
  config,
105
  respondents,
106
  likertAnswers,
107
+ categoricalAnswers,
108
  openAnswers,
109
  regionStats: buildRegionStats(config.locations, config.locationOptions, respondents, likertAnswers, openAnswers, likertQuestions[0]),
110
+ questionStats: buildQuestionStats(config.questions, likertAnswers, categoricalAnswers),
111
  primaryQuestionId,
112
  };
113
  }
 
198
  return { theme, text, rationale };
199
  }
200
 
201
+ function answerCategorical(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { optionId: string; label: string; rationale: string } {
202
+ const options = question.options?.length ? question.options : [{ id: "opt_1", label: "기타" }];
203
+ const ageTilt = respondent.age === "20s" || respondent.age === "30s" ? 0 : respondent.age === "60plus" ? 2 : 1;
204
+ const anxietyTilt = respondent.economicAnxiety > 0.6 ? 0 : 1;
205
+ const index = Math.min(options.length - 1, Math.max(0, Math.floor((rand() * options.length + ageTilt + anxietyTilt) / 3)));
206
+ const option = options[index] || options[0];
207
+ const rationale = `${labelOf(REGIONS, respondent.region)} 거주 ${labelOf(AGE_BANDS, respondent.age)} 응답자로서 현재 생활 여건과 persona 특성에 가장 가까운 선택지를 골랐습니다.`;
208
+ return { optionId: option.id, label: option.label, rationale };
209
+ }
210
+
211
  function buildRegionStats(
212
  locations: WeightedPick<LocationId>[],
213
  locationOptions: SiliconConfig["locationOptions"],
 
238
  });
239
  }
240
 
241
+ function buildQuestionStats(questions: SurveyQuestion[], likertAnswers: LikertAnswer[], categoricalAnswers: CategoricalAnswer[]): QuestionStat[] {
242
  return questions.map((question) => {
243
  if (question.kind === "open") return { questionId: question.id, title: question.title, kind: "open" };
244
+ if (question.kind === "categorical") {
245
+ const answers = categoricalAnswers.filter((answer) => answer.questionId === question.id);
246
+ const options = question.options || [];
247
+ const distribution = options.map((option) => {
248
+ const count = answers.filter((answer) => answer.optionId === option.id).length;
249
+ return { optionId: option.id, label: option.label, count, share: answers.length ? count / answers.length : 0 };
250
+ });
251
+ return {
252
+ questionId: question.id,
253
+ title: question.title,
254
+ kind: "categorical",
255
+ distribution,
256
+ };
257
+ }
258
  const scale = question.scale || 5;
259
  const answers = likertAnswers.filter((answer) => answer.questionId === question.id);
260
  const distribution = Array.from({ length: scale }, (_, index) => {
frontend/src/silicon/types.ts CHANGED
@@ -23,7 +23,7 @@ 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;
@@ -59,6 +59,7 @@ export interface SurveyQuestion {
59
  scale?: 4 | 5 | 7;
60
  lowLabel?: string;
61
  highLabel?: string;
 
62
  }
63
 
64
  export interface WeightedPick<T extends string> {
@@ -109,6 +110,14 @@ export interface OpenAnswer {
109
  rationale?: string;
110
  }
111
 
 
 
 
 
 
 
 
 
112
  export interface RegionStat {
113
  region: LocationId;
114
  parentRegion: RegionId;
@@ -127,13 +136,14 @@ export interface QuestionStat {
127
  scale?: number;
128
  mean?: number;
129
  positiveShare?: number;
130
- distribution?: Array<{ value: number; count: number; share: number }>;
131
  }
132
 
133
  export interface SiliconResult {
134
  config: SiliconConfig;
135
  respondents: SyntheticRespondent[];
136
  likertAnswers: LikertAnswer[];
 
137
  openAnswers: OpenAnswer[];
138
  regionStats: RegionStat[];
139
  questionStats: QuestionStat[];
 
23
  export type PersonaDimensionId = "occupation" | "education" | "housing" | "marital" | "family";
24
  export type PersonaAttributeId = string;
25
 
26
+ export type QuestionKind = "likert" | "categorical" | "open";
27
 
28
  export interface ToggleOption<T extends string> {
29
  id: T;
 
59
  scale?: 4 | 5 | 7;
60
  lowLabel?: string;
61
  highLabel?: string;
62
+ options?: Array<{ id: string; label: string }>;
63
  }
64
 
65
  export interface WeightedPick<T extends string> {
 
110
  rationale?: string;
111
  }
112
 
113
+ export interface CategoricalAnswer {
114
+ respondentId: string;
115
+ questionId: string;
116
+ optionId: string;
117
+ label: string;
118
+ rationale?: string;
119
+ }
120
+
121
  export interface RegionStat {
122
  region: LocationId;
123
  parentRegion: RegionId;
 
136
  scale?: number;
137
  mean?: number;
138
  positiveShare?: number;
139
+ distribution?: Array<{ value?: number; optionId?: string; label?: string; count: number; share: number }>;
140
  }
141
 
142
  export interface SiliconResult {
143
  config: SiliconConfig;
144
  respondents: SyntheticRespondent[];
145
  likertAnswers: LikertAnswer[];
146
+ categoricalAnswers: CategoricalAnswer[];
147
  openAnswers: OpenAnswer[];
148
  regionStats: RegionStat[];
149
  questionStats: QuestionStat[];
frontend/src/styles.css CHANGED
@@ -1080,6 +1080,12 @@ button {
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);
@@ -1143,6 +1149,10 @@ button {
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;
@@ -1566,6 +1576,38 @@ button {
1566
  grid-column: span 2;
1567
  }
1568
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1569
  .custom-question-form label span {
1570
  color: rgba(24, 33, 58, 0.58);
1571
  font-size: 11px;
@@ -1596,6 +1638,10 @@ button {
1596
  border-left: 4px solid #f08a6c;
1597
  }
1598
 
 
 
 
 
1599
  .final-question-list article span {
1600
  color: rgba(24, 33, 58, 0.58);
1601
  font-size: 11px;
 
1080
  border-top-color: #f08a6c;
1081
  }
1082
 
1083
+ .question-bank button.kind-categorical,
1084
+ .question-tabs button.kind-categorical {
1085
+ border-left-color: #14b8a6;
1086
+ border-top-color: #14b8a6;
1087
+ }
1088
+
1089
  .question-tabs button.active {
1090
  border-color: rgba(91, 110, 225, 0.38);
1091
  background: linear-gradient(180deg, #f3f5ff, #ffffff);
 
1149
  border-left: 4px solid #f08a6c;
1150
  }
1151
 
1152
+ .custom-question-list article.kind-categorical {
1153
+ border-left: 4px solid #14b8a6;
1154
+ }
1155
+
1156
  .custom-question-list em {
1157
  color: rgba(24, 33, 58, 0.56);
1158
  font-size: 11px;
 
1576
  grid-column: span 2;
1577
  }
1578
 
1579
+ .categorical-options-editor {
1580
+ grid-column: 1 / -1;
1581
+ display: grid;
1582
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1583
+ gap: 10px;
1584
+ border: 1px solid rgba(20, 184, 166, 0.18);
1585
+ border-radius: 16px;
1586
+ padding: 12px;
1587
+ background: linear-gradient(180deg, rgba(236, 253, 245, 0.7), rgba(255, 255, 255, 0.92));
1588
+ }
1589
+
1590
+ .categorical-options-head {
1591
+ grid-column: 1 / -1;
1592
+ display: flex;
1593
+ justify-content: space-between;
1594
+ align-items: center;
1595
+ gap: 10px;
1596
+ }
1597
+
1598
+ .categorical-options-head span {
1599
+ color: rgba(24, 33, 58, 0.62);
1600
+ font-size: 11px;
1601
+ font-weight: 850;
1602
+ }
1603
+
1604
+ .categorical-options-head button {
1605
+ min-height: 32px;
1606
+ padding: 6px 10px;
1607
+ background: #ecfdf5;
1608
+ color: #0f766e;
1609
+ }
1610
+
1611
  .custom-question-form label span {
1612
  color: rgba(24, 33, 58, 0.58);
1613
  font-size: 11px;
 
1638
  border-left: 4px solid #f08a6c;
1639
  }
1640
 
1641
+ .final-question-list article.kind-categorical {
1642
+ border-left: 4px solid #14b8a6;
1643
+ }
1644
+
1645
  .final-question-list article span {
1646
  color: rgba(24, 33, 58, 0.58);
1647
  font-size: 11px;
scripts/test_silicon_llm_runtime.py CHANGED
@@ -40,6 +40,16 @@ QUESTIONS = [
40
  "title": "가장 먼저 해결해야 할 문제는 무엇입니까?",
41
  "kind": "open",
42
  },
 
 
 
 
 
 
 
 
 
 
43
  ]
44
 
45
 
@@ -55,6 +65,7 @@ class FakeLLM:
55
  "q_likert_5": {"value": 4, "reason": "정책 평가는 대체로 긍정적입니다."},
56
  "q_likert_4": {"value": 2, "reason": "의료 접근성은 지역에 따라 부족합니다."},
57
  "q_open": {"text": "물가와 의료 접근성 개선이 가장 시급합니다.", "theme": "의료", "rationale": "생활비와 의료 이용 경험을 함께 고려했습니다."},
 
58
  }
59
  },
60
  ensure_ascii=False,
@@ -66,13 +77,14 @@ class SiliconLLMRuntimeTests(unittest.TestCase):
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("rationale", answer_properties["q_likert_4"]["required"])
74
  self.assertIn("text", answer_properties["q_open"]["required"])
75
  self.assertIn("rationale", answer_properties["q_open"]["required"])
 
76
 
77
  def test_parser_extracts_all_dynamic_answers_from_fenced_json(self) -> None:
78
  raw = """
@@ -82,7 +94,8 @@ class SiliconLLMRuntimeTests(unittest.TestCase):
82
  "answers": {
83
  "q_likert_5": {"value": "5", "reason": "강한 긍정"},
84
  "q_likert_4": {"score": 3, "reason": "보통 이상"},
85
- "q_open": {"answer": "교통과 돌봄이 필요합니다", "topic": "교통"}
 
86
  }
87
  }
88
  ```
@@ -90,10 +103,13 @@ class SiliconLLMRuntimeTests(unittest.TestCase):
90
  parsed = parse_silicon_agent_response(raw, QUESTIONS, respondent_id="R0001")
91
 
92
  self.assertEqual(len(parsed["likertAnswers"]), 2)
 
93
  self.assertEqual(len(parsed["openAnswers"]), 1)
94
  self.assertEqual(parsed["likertAnswers"][0]["value"], 5)
95
  self.assertEqual(parsed["likertAnswers"][1]["value"], 3)
96
  self.assertEqual(parsed["likertAnswers"][0]["rationale"], "강한 긍정")
 
 
97
  self.assertEqual(parsed["openAnswers"][0]["theme"], "교통")
98
  self.assertTrue(parsed["openAnswers"][0]["rationale"])
99
 
@@ -117,14 +133,17 @@ class SiliconLLMRuntimeTests(unittest.TestCase):
117
  result = run_silicon_llm_sampling(payload, llm=fake)
118
 
119
  self.assertEqual(len(fake.calls), 2)
120
- self.assertTrue(all(len(call["questions"]) == 3 for call in fake.calls))
121
  self.assertEqual(len(result["respondents"]), 2)
122
  self.assertEqual(len(result["likertAnswers"]), 4)
 
123
  self.assertEqual(len(result["openAnswers"]), 2)
124
  self.assertTrue(all(answer.get("rationale") for answer in result["likertAnswers"]))
 
125
  self.assertTrue(all(answer.get("rationale") for answer in result["openAnswers"]))
126
- self.assertEqual([stat["questionId"] for stat in result["questionStats"]], ["q_likert_5", "q_likert_4", "q_open"])
127
  self.assertEqual(result["questionStats"][0]["distribution"][3]["count"], 2)
 
128
  self.assertEqual(result["regionStats"][0]["respondents"], 2)
129
  self.assertEqual(result["llmTrace"]["calls"], 2)
130
 
 
40
  "title": "가장 먼저 해결해야 할 문제는 무엇입니까?",
41
  "kind": "open",
42
  },
43
+ {
44
+ "id": "q_categorical",
45
+ "title": "삶에서 가장 중요한 것을 하나만 고르십시오.",
46
+ "kind": "categorical",
47
+ "options": [
48
+ {"id": "money", "label": "돈"},
49
+ {"id": "honor", "label": "명예"},
50
+ {"id": "health", "label": "건강"},
51
+ ],
52
+ },
53
  ]
54
 
55
 
 
65
  "q_likert_5": {"value": 4, "reason": "정책 평가는 대체로 긍정적입니다."},
66
  "q_likert_4": {"value": 2, "reason": "의료 접근성은 지역에 따라 부족합니다."},
67
  "q_open": {"text": "물가와 의료 접근성 개선이 가장 시급합니다.", "theme": "의료", "rationale": "생활비와 의료 이용 경험을 함께 고려했습니다."},
68
+ "q_categorical": {"optionId": "health", "label": "건강", "rationale": "고령 가족 돌봄 경험 때문에 건강을 우선했습니다."},
69
  }
70
  },
71
  ensure_ascii=False,
 
77
  contract = build_silicon_llm_response_contract(QUESTIONS)
78
 
79
  answer_properties = contract["schema"]["properties"]["answers"]["properties"]
80
+ self.assertEqual(set(answer_properties), {"q_likert_5", "q_likert_4", "q_open", "q_categorical"})
81
  self.assertEqual(answer_properties["q_likert_5"]["properties"]["value"]["minimum"], 1)
82
  self.assertEqual(answer_properties["q_likert_5"]["properties"]["value"]["maximum"], 5)
83
  self.assertEqual(answer_properties["q_likert_4"]["properties"]["value"]["maximum"], 4)
84
  self.assertIn("rationale", answer_properties["q_likert_4"]["required"])
85
  self.assertIn("text", answer_properties["q_open"]["required"])
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 = """
 
94
  "answers": {
95
  "q_likert_5": {"value": "5", "reason": "강한 긍정"},
96
  "q_likert_4": {"score": 3, "reason": "보통 이상"},
97
+ "q_open": {"answer": "교통과 돌봄이 필요합니다", "topic": "교통"},
98
+ "q_categorical": {"label": "건강", "rationale": "생활 안정의 전제라고 보았습니다."}
99
  }
100
  }
101
  ```
 
103
  parsed = parse_silicon_agent_response(raw, QUESTIONS, respondent_id="R0001")
104
 
105
  self.assertEqual(len(parsed["likertAnswers"]), 2)
106
+ self.assertEqual(len(parsed["categoricalAnswers"]), 1)
107
  self.assertEqual(len(parsed["openAnswers"]), 1)
108
  self.assertEqual(parsed["likertAnswers"][0]["value"], 5)
109
  self.assertEqual(parsed["likertAnswers"][1]["value"], 3)
110
  self.assertEqual(parsed["likertAnswers"][0]["rationale"], "강한 긍정")
111
+ self.assertEqual(parsed["categoricalAnswers"][0]["optionId"], "health")
112
+ self.assertEqual(parsed["categoricalAnswers"][0]["label"], "건강")
113
  self.assertEqual(parsed["openAnswers"][0]["theme"], "교통")
114
  self.assertTrue(parsed["openAnswers"][0]["rationale"])
115
 
 
133
  result = run_silicon_llm_sampling(payload, llm=fake)
134
 
135
  self.assertEqual(len(fake.calls), 2)
136
+ self.assertTrue(all(len(call["questions"]) == 4 for call in fake.calls))
137
  self.assertEqual(len(result["respondents"]), 2)
138
  self.assertEqual(len(result["likertAnswers"]), 4)
139
+ self.assertEqual(len(result["categoricalAnswers"]), 2)
140
  self.assertEqual(len(result["openAnswers"]), 2)
141
  self.assertTrue(all(answer.get("rationale") for answer in result["likertAnswers"]))
142
+ self.assertTrue(all(answer.get("rationale") for answer in result["categoricalAnswers"]))
143
  self.assertTrue(all(answer.get("rationale") for answer in result["openAnswers"]))
144
+ self.assertEqual([stat["questionId"] for stat in result["questionStats"]], ["q_likert_5", "q_likert_4", "q_open", "q_categorical"])
145
  self.assertEqual(result["questionStats"][0]["distribution"][3]["count"], 2)
146
+ self.assertEqual(result["questionStats"][3]["distribution"][2]["count"], 2)
147
  self.assertEqual(result["regionStats"][0]["respondents"], 2)
148
  self.assertEqual(result["llmTrace"]["calls"], 2)
149
 
scripts/test_silicon_logs.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Unit tests for anonymous silicon sampling run logs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sqlite3
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 silicon_logs import log_status, record_run, recent_runs # noqa: E402
18
+
19
+
20
+ class SiliconLogTests(unittest.TestCase):
21
+ def test_record_run_writes_anonymous_settings_questions_and_summary(self) -> None:
22
+ with tempfile.TemporaryDirectory() as tmp:
23
+ path = Path(tmp) / "logs.sqlite3"
24
+ old = os.environ.get("SILICON_LOG_DB")
25
+ os.environ["SILICON_LOG_DB"] = str(path)
26
+ try:
27
+ info = record_run(
28
+ {
29
+ "config": {
30
+ "sampleSize": 2,
31
+ "genders": [{"id": "male", "enabled": True, "weight": 1}],
32
+ "ages": [{"id": "30s", "enabled": True, "weight": 2}],
33
+ "locations": [{"id": "seoul", "enabled": True, "weight": 2}],
34
+ "locationOptions": [{"id": "seoul", "label": "서울", "parentRegion": "seoul", "level": "sido"}],
35
+ "personaAttributes": [{"id": "occ_office", "enabled": True, "weight": 2}],
36
+ "nemotronFields": ["persona", "occupation"],
37
+ "questions": [
38
+ {"id": "q1", "title": "만족도", "kind": "likert", "scale": 5},
39
+ {"id": "q2", "title": "중요 가치", "kind": "categorical", "options": [{"id": "health", "label": "건강"}]},
40
+ ],
41
+ }
42
+ },
43
+ {
44
+ "respondents": [{"id": "R0001"}, {"id": "R0002"}],
45
+ "likertAnswers": [{"questionId": "q1"}, {"questionId": "q1"}],
46
+ "categoricalAnswers": [{"questionId": "q2"}, {"questionId": "q2"}],
47
+ "openAnswers": [],
48
+ "questionStats": [{"questionId": "q1"}, {"questionId": "q2"}],
49
+ "regionStats": [],
50
+ "llmTrace": {"calls": 2, "rawResponses": ["not stored"]},
51
+ },
52
+ client={"userAgent": "unit-test"},
53
+ )
54
+ self.assertTrue(info["runId"].startswith("run_"))
55
+ self.assertTrue(path.exists())
56
+ self.assertEqual(log_status()["runCount"], 1)
57
+ rows = recent_runs()
58
+ self.assertEqual(len(rows), 1)
59
+ self.assertEqual(rows[0]["sampleSize"], 2)
60
+ self.assertEqual(rows[0]["questions"][1]["kind"], "categorical")
61
+ self.assertEqual(rows[0]["resultSummary"]["categoricalAnswers"], 2)
62
+ self.assertNotIn("rawResponses", rows[0]["resultSummary"]["llmTrace"])
63
+ with sqlite3.connect(path) as conn:
64
+ self.assertEqual(conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0], 1)
65
+ finally:
66
+ if old is None:
67
+ os.environ.pop("SILICON_LOG_DB", None)
68
+ else:
69
+ os.environ["SILICON_LOG_DB"] = old
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main(verbosity=2)
scripts/validate_silicon_llm_api.py CHANGED
@@ -7,6 +7,7 @@ import argparse
7
  import json
8
  import sys
9
  import time
 
10
  from pathlib import Path
11
  from typing import Any
12
 
@@ -19,36 +20,41 @@ from silicon_llm import resolve_openai_api_key, run_silicon_llm_sampling # noqa
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
@@ -59,6 +65,19 @@ def main() -> int:
59
  return 0 if report["ok"] else 1
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def validation_cases() -> list[dict[str, Any]]:
63
  return [
64
  {
@@ -66,12 +85,14 @@ def validation_cases() -> list[dict[str, Any]]:
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
  ],
@@ -81,6 +102,7 @@ def validation_cases() -> list[dict[str, Any]]:
81
  "questions": [
82
  likert("vote_turnout_4", "다음 지방선거에 투표할 가능성�� 얼마나 높습니까?", 4),
83
  likert("party_reflects_5", "주요 정당이 국민 의견을 잘 반영한다고 보십니까?", 5),
 
84
  likert("climate_cost_7", "탄소 감축 비용 부담 정책에 어느 정도 동의하십니까?", 7),
85
  ],
86
  },
@@ -88,6 +110,7 @@ def validation_cases() -> list[dict[str, Any]]:
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
  ],
@@ -98,6 +121,7 @@ def validation_cases() -> list[dict[str, Any]]:
98
  likert("economy_next_year", "향후 1년 한국 경제가 좋아질 것이라고 보십니까?", 5),
99
  likert("household_life", "현재 가계 생활 형편에 얼마나 만족하십니까?", 5),
100
  likert("institution_trust", "국회, 언론, 행정부 등 공공기관을 전반적으로 신뢰하십니까?", 5),
 
101
  open_q("free_reason", "그렇게 응답한 가장 큰 이유를 적어주십시오."),
102
  ],
103
  },
@@ -112,6 +136,17 @@ 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": {
@@ -139,14 +174,18 @@ def base_payload(agent_count: int, questions: list[dict[str, Any]], *, model: st
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)
@@ -156,6 +195,14 @@ def validate_result(case_id: str, result: dict[str, Any], questions: list[dict[s
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
  rationales = [answer.get("rationale") for answer in result.get("likertAnswers", []) if answer.get("questionId") == question_id]
158
  checks.append(check(f"{case_id}:{question_id}:likert_rationale", len(rationales) == agent_count and all(rationales), rationales[:2]))
 
 
 
 
 
 
 
 
159
  for question in open_questions:
160
  answers = [answer for answer in result.get("openAnswers", []) if answer.get("questionId") == question["id"]]
161
  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]))
 
7
  import json
8
  import sys
9
  import time
10
+ from urllib import request
11
  from pathlib import Path
12
  from typing import Any
13
 
 
20
 
21
  def main() -> int:
22
  parser = argparse.ArgumentParser()
23
+ parser.add_argument("--agents", type=int, default=2)
24
+ parser.add_argument("--max-cases", type=int, default=3)
25
  parser.add_argument("--model", default="gpt-4o-mini")
26
+ parser.add_argument("--endpoint", default="")
27
  parser.add_argument("--out", default="")
28
  args = parser.parse_args()
29
+ if not args.endpoint and not resolve_openai_api_key():
30
  print(json.dumps({"ok": False, "error": "OPENAI_API_KEY or .env OPENAI_API_KEY is required"}, ensure_ascii=False, indent=2))
31
  return 1
32
  out_dir = Path(args.out) if args.out else ROOT / "runs" / f"silicon_llm_api_validation_{timestamp()}"
33
  out_dir.mkdir(parents=True, exist_ok=True)
34
+ report: dict[str, Any] = {"ok": False, "model": args.model, "agents": args.agents, "maxCases": args.max_cases, "endpoint": args.endpoint, "cases": []}
35
+ for case in validation_cases()[: max(1, args.max_cases)]:
36
  payload = base_payload(args.agents, case["questions"], model=args.model)
37
  started = time.time()
38
  try:
39
+ result = run_payload(payload, endpoint=args.endpoint)
40
  checks = validate_result(case["case_id"], result, case["questions"], args.agents)
41
  report["cases"].append(
42
  {
43
  "case_id": case["case_id"],
44
  "ok": all(check["ok"] for check in checks),
45
  "elapsed_seconds": round(time.time() - started, 2),
46
+ "questions": [{"id": item["id"], "kind": item["kind"], "scale": item.get("scale"), "options": item.get("options")} for item in case["questions"]],
47
  "checks": checks,
48
  "llmTrace": {
49
  key: value
50
  for key, value in result.get("llmTrace", {}).items()
51
  if key != "rawResponses"
52
  },
53
+ "sample_categorical_answers": result.get("categoricalAnswers", [])[:3],
54
  "sample_open_answers": result.get("openAnswers", [])[:3],
55
  "questionStats": result.get("questionStats", []),
56
+ "runLog": result.get("runLog"),
57
+ "runLogError": result.get("runLogError"),
58
  }
59
  )
60
  except Exception as exc: # noqa: BLE001
 
65
  return 0 if report["ok"] else 1
66
 
67
 
68
+ def run_payload(payload: dict[str, Any], *, endpoint: str) -> dict[str, Any]:
69
+ if not endpoint:
70
+ return run_silicon_llm_sampling(payload)
71
+ url = endpoint.rstrip("/") + "/api/silicon/llm-run"
72
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
73
+ req = request.Request(url, data=data, headers={"content-type": "application/json"}, method="POST")
74
+ with request.urlopen(req, timeout=180) as response: # noqa: S310
75
+ result = json.loads(response.read().decode("utf-8"))
76
+ if "error" in result:
77
+ raise RuntimeError(str(result["error"]))
78
+ return result
79
+
80
+
81
  def validation_cases() -> list[dict[str, Any]]:
82
  return [
83
  {
 
85
  "questions": [
86
  likert("approval_5", "현 정부 국정 운영을 어떻게 평가하십니까?", 5),
87
  likert("medical_access_4", "거주 지역 의료 접근성에 만족하십니까?", 4),
88
+ categorical("life_priority", "삶에서 가장 중요한 것을 하나만 고르십시오.", ["돈", "명예", "건강"]),
89
  open_q("local_problem", "거주 지역에서 가장 먼저 해결해야 할 문제는 무엇입니까?"),
90
  ],
91
  },
92
  {
93
  "case_id": "open_only_priorities",
94
  "questions": [
95
+ categorical("social_priority", "한국 사회가 가장 우선해야 할 가치를 고르십시오.", ["경제 성장", "사회 안전", "공정성", "환경"]),
96
  open_q("top_issue", "한국 사회가 가장 먼저 해결해야 할 문제는 무엇이라고 보십니까?"),
97
  open_q("policy_request", "새 정부나 지방자치단체에 가장 바라는 정책을 적어주십시오."),
98
  ],
 
102
  "questions": [
103
  likert("vote_turnout_4", "다음 지방선거에 투표할 가능성�� 얼마나 높습니까?", 4),
104
  likert("party_reflects_5", "주요 정당이 국민 의견을 잘 반영한다고 보십니까?", 5),
105
+ categorical("vote_factor", "투표할 때 가장 중요하게 보는 기준을 고르십시오.", ["후보 역량", "정당", "공약", "지역 현안"]),
106
  likert("climate_cost_7", "탄소 감축 비용 부담 정책에 어느 정도 동의하십니까?", 7),
107
  ],
108
  },
 
110
  "case_id": "custom_korean_mix",
111
  "questions": [
112
  likert("custom_transport_safety", "출퇴근길 대중교통 안전에 만족하십니까?", 5),
113
+ categorical("custom_life_choice", "지금 생활에서 가장 크게 필요한 것을 고르십시오.", ["시간", "소득", "건강", "관계"]),
114
  open_q("custom_one_sentence", "본인의 생활에서 가장 크게 체감되는 정책 문제를 한 문장으로 적어주십시오."),
115
  likert("custom_media_trust", "온라인 뉴스와 유튜브 정치 정보를 신뢰하십니까?", 5),
116
  ],
 
121
  likert("economy_next_year", "향후 1년 한국 경제가 좋아질 것이라고 보십니까?", 5),
122
  likert("household_life", "현재 가계 생활 형편에 얼마나 만족하십니까?", 5),
123
  likert("institution_trust", "국회, 언론, 행정부 등 공공기관을 전반적으로 신뢰하십니까?", 5),
124
+ categorical("public_spend_priority", "정부 재정이 우선 투입되어야 할 분야를 고르십시오.", ["복지", "일자리", "교육", "안보", "기후"]),
125
  open_q("free_reason", "그렇게 응답한 가장 큰 이유를 적어주십시오."),
126
  ],
127
  },
 
136
  return {"id": question_id, "title": title, "source": "API validation", "category": "검증", "kind": "open"}
137
 
138
 
139
+ def categorical(question_id: str, title: str, labels: list[str]) -> dict[str, Any]:
140
+ return {
141
+ "id": question_id,
142
+ "title": title,
143
+ "source": "API validation",
144
+ "category": "검증",
145
+ "kind": "categorical",
146
+ "options": [{"id": f"opt_{index + 1}", "label": label} for index, label in enumerate(labels)],
147
+ }
148
+
149
+
150
  def base_payload(agent_count: int, questions: list[dict[str, Any]], *, model: str) -> dict[str, Any]:
151
  return {
152
  "config": {
 
174
  def validate_result(case_id: str, result: dict[str, Any], questions: list[dict[str, Any]], agent_count: int) -> list[dict[str, Any]]:
175
  checks: list[dict[str, Any]] = []
176
  likert_questions = [item for item in questions if item["kind"] == "likert"]
177
+ categorical_questions = [item for item in questions if item["kind"] == "categorical"]
178
  open_questions = [item for item in questions if item["kind"] == "open"]
179
  checks.append(check("respondent_count", len(result.get("respondents", [])) == agent_count, {"count": len(result.get("respondents", [])), "expected": agent_count}))
180
  checks.append(check("one_llm_call_per_agent", result.get("llmTrace", {}).get("calls") == agent_count, result.get("llmTrace", {})))
181
  checks.append(check("all_questions_in_config", [item["id"] for item in result.get("config", {}).get("questions", [])] == [item["id"] for item in questions], None))
182
  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)}))
183
+ checks.append(check("categorical_answer_count", len(result.get("categoricalAnswers", [])) == agent_count * len(categorical_questions), {"count": len(result.get("categoricalAnswers", [])), "expected": agent_count * len(categorical_questions)}))
184
  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)}))
185
  checks.append(check("question_stats_count", len(result.get("questionStats", [])) == len(questions), {"count": len(result.get("questionStats", [])), "expected": len(questions)}))
186
  checks.append(check("parse_issues_empty", not result.get("llmTrace", {}).get("parseIssues"), result.get("llmTrace", {}).get("parseIssues")))
187
+ if result.get("runLog") or result.get("runLogError"):
188
+ checks.append(check("run_log_recorded", bool(result.get("runLog")) and not result.get("runLogError"), result.get("runLog") or result.get("runLogError")))
189
  for question in likert_questions:
190
  question_id = question["id"]
191
  scale = int(question.get("scale") or 5)
 
195
  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")))
196
  rationales = [answer.get("rationale") for answer in result.get("likertAnswers", []) if answer.get("questionId") == question_id]
197
  checks.append(check(f"{case_id}:{question_id}:likert_rationale", len(rationales) == agent_count and all(rationales), rationales[:2]))
198
+ for question in categorical_questions:
199
+ question_id = question["id"]
200
+ allowed = {option["id"] for option in question.get("options", [])}
201
+ answers = [answer for answer in result.get("categoricalAnswers", []) if answer.get("questionId") == question_id]
202
+ stat = next((item for item in result.get("questionStats", []) if item.get("questionId") == question_id), {})
203
+ checks.append(check(f"{case_id}:{question_id}:categorical_option", len(answers) == agent_count and all(answer.get("optionId") in allowed for answer in answers), answers[:2]))
204
+ checks.append(check(f"{case_id}:{question_id}:categorical_distribution_sum", sum(item.get("count", 0) for item in stat.get("distribution", [])) == agent_count, stat.get("distribution")))
205
+ checks.append(check(f"{case_id}:{question_id}:categorical_rationale", len(answers) == agent_count and all(answer.get("rationale") for answer in answers), answers[:2]))
206
  for question in open_questions:
207
  answers = [answer for answer in result.get("openAnswers", []) if answer.get("questionId") == question["id"]]
208
  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]))
server.py CHANGED
@@ -5,9 +5,10 @@ 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
 
@@ -73,8 +74,17 @@ class Handler(BaseHTTPRequestHandler):
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())
@@ -97,7 +107,19 @@ class Handler(BaseHTTPRequestHandler):
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:
 
5
  from http import HTTPStatus
6
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
  from pathlib import Path
8
+ 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
 
 
74
  "ok": True,
75
  "openai_api_key_set": bool(resolve_openai_api_key()),
76
  "dataset": dataset_status(),
77
+ "logs": log_status(),
78
  },
79
  )
80
+ if path == "/api/logs/status":
81
+ return write_json(self, log_status())
82
+ if path == "/api/logs/recent":
83
+ token = os.getenv("SILICON_LOG_READ_TOKEN", "").strip()
84
+ provided = parse_qs(parsed.query).get("token", [""])[0]
85
+ if not token or provided != token:
86
+ return write_json(self, {"error": "log read token required", "status": log_status()}, HTTPStatus.FORBIDDEN)
87
+ return write_json(self, {"runs": recent_runs(20), "status": log_status()})
88
  if path == "/api/persona-dataset/metadata":
89
  try:
90
  return write_json(self, dataset_metadata())
 
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)
111
+ try:
112
+ result["runLog"] = record_run(
113
+ payload,
114
+ result,
115
+ client={
116
+ "userAgent": self.headers.get("user-agent", "")[:240],
117
+ "referer": self.headers.get("referer", "")[:240],
118
+ },
119
+ )
120
+ except Exception as log_exc: # noqa: BLE001
121
+ result["runLogError"] = f"{type(log_exc).__name__}: {log_exc}"
122
+ return write_json(self, result)
123
  except ValueError as exc:
124
  return write_json(self, {"error": str(exc)}, HTTPStatus.BAD_REQUEST)
125
  except RuntimeError as exc:
silicon_llm.py CHANGED
@@ -74,6 +74,7 @@ class OpenAILLM:
74
  "당신은 한국 설문조사의 가상 응답자입니다. "
75
  "주어진 persona와 인구통계 정보를 일관되게 반영해 모든 문항에 답하십시오. "
76
  "반드시 JSON만 반환하십시오. Likert 문항은 해당 척도 범위의 정수 value를, "
 
77
  "open-ended 문항은 한국어 text와 짧은 theme를 반환하십시오. "
78
  "모든 답변에는 왜 그렇게 답했는지 한 문장의 rationale을 포함하십시오."
79
  ),
@@ -148,6 +149,19 @@ def build_silicon_llm_response_contract(questions: list[dict[str, Any]]) -> dict
148
  "required": ["text", "theme", "rationale"],
149
  "additionalProperties": True,
150
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  else:
152
  scale = int(question.get("scale") or 5)
153
  answer_properties[question_id] = {
@@ -186,6 +200,7 @@ def parse_silicon_agent_response(raw: str, questions: list[dict[str, Any]], *, r
186
  answers = {}
187
 
188
  likert_answers: list[dict[str, Any]] = []
 
189
  open_answers: list[dict[str, Any]] = []
190
  issues: list[dict[str, Any]] = []
191
  for question in questions:
@@ -205,6 +220,21 @@ def parse_silicon_agent_response(raw: str, questions: list[dict[str, Any]], *, r
205
  rationale = "해당 persona와 인구통계 조건을 기준으로 자유응답을 생성했습니다."
206
  issues.append({"questionId": question_id, "type": "missing_rationale"})
207
  open_answers.append({"respondentId": respondent_id, "questionId": question_id, "text": text, "theme": theme[:40], "rationale": rationale[:500]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  else:
209
  scale = int(question.get("scale") or 5)
210
  value = coerce_int(answer.get("value", answer.get("score", answer.get("rating"))), default=math.ceil(scale / 2))
@@ -214,7 +244,7 @@ def parse_silicon_agent_response(raw: str, questions: list[dict[str, Any]], *, r
214
  rationale = "해당 persona와 인구통계 조건을 기준으로 척도 응답을 선택했습니다."
215
  issues.append({"questionId": question_id, "type": "missing_rationale"})
216
  likert_answers.append({"respondentId": respondent_id, "questionId": question_id, "value": value, "rationale": rationale[:500]})
217
- return {"likertAnswers": likert_answers, "openAnswers": open_answers, "issues": issues}
218
 
219
 
220
  def run_silicon_llm_sampling(payload: dict[str, Any], *, llm: Any | None = None) -> dict[str, Any]:
@@ -234,6 +264,7 @@ def run_silicon_llm_sampling(payload: dict[str, Any], *, llm: Any | None = None)
234
  model = str(execution.get("model") or os.getenv("SILICON_LLM_MODEL", "gpt-4o-mini"))
235
  llm = llm or OpenAILLM(model=model, timeout=float(execution.get("timeout_seconds") or 60))
236
  likert_answers: list[dict[str, Any]] = []
 
237
  open_answers: list[dict[str, Any]] = []
238
  parse_issues: list[dict[str, Any]] = []
239
  raw_responses: list[dict[str, str]] = []
@@ -243,17 +274,19 @@ def run_silicon_llm_sampling(payload: dict[str, Any], *, llm: Any | None = None)
243
  raw_responses.append({"respondentId": respondent["id"], "text": raw[:4000]})
244
  parsed = parse_silicon_agent_response(raw, questions, respondent_id=respondent["id"])
245
  likert_answers.extend(parsed["likertAnswers"])
 
246
  open_answers.extend(parsed["openAnswers"])
247
  parse_issues.extend({"respondentId": respondent["id"], **issue} for issue in parsed["issues"])
248
 
249
- primary_question_id = next((str(item.get("id")) for item in questions if item.get("kind") != "open"), None)
250
  result = {
251
  "config": config,
252
  "respondents": respondents,
253
  "likertAnswers": likert_answers,
 
254
  "openAnswers": open_answers,
255
  "regionStats": build_region_stats(config, respondents, likert_answers, open_answers, questions),
256
- "questionStats": build_question_stats(questions, likert_answers),
257
  "primaryQuestionId": primary_question_id,
258
  "llmTrace": {
259
  "engine": "openai_chat_completions" if isinstance(llm, OpenAILLM) else "injected_llm",
@@ -329,13 +362,30 @@ def allocation_pool(items: Any, target_size: int, fallback: str) -> list[str]:
329
  return pool[:target_size]
330
 
331
 
332
- def build_question_stats(questions: list[dict[str, Any]], likert_answers: list[dict[str, Any]]) -> list[dict[str, Any]]:
333
  stats: list[dict[str, Any]] = []
334
  for question in questions:
335
  question_id = str(question.get("id"))
336
  if question.get("kind") == "open":
337
  stats.append({"questionId": question_id, "title": question.get("title", question_id), "kind": "open"})
338
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  scale = int(question.get("scale") or 5)
340
  values = [int(answer["value"]) for answer in likert_answers if answer.get("questionId") == question_id]
341
  distribution = [{"value": value, "count": values.count(value), "share": values.count(value) / len(values) if values else 0} for value in range(1, scale + 1)]
@@ -355,7 +405,7 @@ def build_question_stats(questions: list[dict[str, Any]], likert_answers: list[d
355
 
356
 
357
  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]]:
358
- primary = next((question for question in questions if question.get("kind") != "open"), None)
359
  primary_id = str(primary.get("id")) if primary else None
360
  scale = int(primary.get("scale") or 5) if primary else 5
361
  positive_cut = max(3, math.ceil(scale * 0.7))
 
74
  "당신은 한국 설문조사의 가상 응답자입니다. "
75
  "주어진 persona와 인구통계 정보를 일관되게 반영해 모든 문항에 답하십시오. "
76
  "반드시 JSON만 반환하십시오. Likert 문항은 해당 척도 범위의 정수 value를, "
77
+ "categorical 문항은 제공된 options 중 하나의 optionId와 label을, "
78
  "open-ended 문항은 한국어 text와 짧은 theme를 반환하십시오. "
79
  "모든 답변에는 왜 그렇게 답했는지 한 문장의 rationale을 포함하십시오."
80
  ),
 
149
  "required": ["text", "theme", "rationale"],
150
  "additionalProperties": True,
151
  }
152
+ elif question.get("kind") == "categorical":
153
+ options = [item for item in question.get("options", []) if isinstance(item, dict)]
154
+ option_ids = [str(item.get("id")) for item in options if item.get("id")]
155
+ answer_properties[question_id] = {
156
+ "type": "object",
157
+ "properties": {
158
+ "optionId": {"type": "string", "enum": option_ids or ["opt_1"]},
159
+ "label": {"type": "string"},
160
+ "rationale": {"type": "string"},
161
+ },
162
+ "required": ["optionId", "label", "rationale"],
163
+ "additionalProperties": True,
164
+ }
165
  else:
166
  scale = int(question.get("scale") or 5)
167
  answer_properties[question_id] = {
 
200
  answers = {}
201
 
202
  likert_answers: list[dict[str, Any]] = []
203
+ categorical_answers: list[dict[str, Any]] = []
204
  open_answers: list[dict[str, Any]] = []
205
  issues: list[dict[str, Any]] = []
206
  for question in questions:
 
220
  rationale = "해당 persona와 인구통계 조건을 기준으로 자유응답을 생성했습니다."
221
  issues.append({"questionId": question_id, "type": "missing_rationale"})
222
  open_answers.append({"respondentId": respondent_id, "questionId": question_id, "text": text, "theme": theme[:40], "rationale": rationale[:500]})
223
+ elif question.get("kind") == "categorical":
224
+ options = [item for item in question.get("options", []) if isinstance(item, dict)]
225
+ option_by_id = {str(item.get("id")): str(item.get("label") or item.get("id")) for item in options if item.get("id")}
226
+ fallback_id = next(iter(option_by_id), "opt_1")
227
+ option_id = str(answer.get("optionId") or answer.get("option_id") or answer.get("value") or "").strip()
228
+ if option_id not in option_by_id:
229
+ matched = next((oid for oid, label in option_by_id.items() if label == str(answer.get("label") or answer.get("answer") or "").strip()), "")
230
+ option_id = matched or fallback_id
231
+ issues.append({"questionId": question_id, "type": "invalid_categorical_option"})
232
+ label = option_by_id.get(option_id, str(answer.get("label") or option_id))
233
+ rationale = str(answer.get("rationale") or answer.get("reason") or answer.get("explanation") or "").strip()
234
+ if not rationale:
235
+ rationale = "해당 persona와 인구통계 조건에 가장 가까운 선택지를 골랐습니다."
236
+ issues.append({"questionId": question_id, "type": "missing_rationale"})
237
+ categorical_answers.append({"respondentId": respondent_id, "questionId": question_id, "optionId": option_id, "label": label, "rationale": rationale[:500]})
238
  else:
239
  scale = int(question.get("scale") or 5)
240
  value = coerce_int(answer.get("value", answer.get("score", answer.get("rating"))), default=math.ceil(scale / 2))
 
244
  rationale = "해당 persona와 인구통계 조건을 기준으로 척도 응답을 선택했습니다."
245
  issues.append({"questionId": question_id, "type": "missing_rationale"})
246
  likert_answers.append({"respondentId": respondent_id, "questionId": question_id, "value": value, "rationale": rationale[:500]})
247
+ return {"likertAnswers": likert_answers, "categoricalAnswers": categorical_answers, "openAnswers": open_answers, "issues": issues}
248
 
249
 
250
  def run_silicon_llm_sampling(payload: dict[str, Any], *, llm: Any | None = None) -> dict[str, Any]:
 
264
  model = str(execution.get("model") or os.getenv("SILICON_LLM_MODEL", "gpt-4o-mini"))
265
  llm = llm or OpenAILLM(model=model, timeout=float(execution.get("timeout_seconds") or 60))
266
  likert_answers: list[dict[str, Any]] = []
267
+ categorical_answers: list[dict[str, Any]] = []
268
  open_answers: list[dict[str, Any]] = []
269
  parse_issues: list[dict[str, Any]] = []
270
  raw_responses: list[dict[str, str]] = []
 
274
  raw_responses.append({"respondentId": respondent["id"], "text": raw[:4000]})
275
  parsed = parse_silicon_agent_response(raw, questions, respondent_id=respondent["id"])
276
  likert_answers.extend(parsed["likertAnswers"])
277
+ categorical_answers.extend(parsed["categoricalAnswers"])
278
  open_answers.extend(parsed["openAnswers"])
279
  parse_issues.extend({"respondentId": respondent["id"], **issue} for issue in parsed["issues"])
280
 
281
+ primary_question_id = next((str(item.get("id")) for item in questions if item.get("kind") == "likert"), None)
282
  result = {
283
  "config": config,
284
  "respondents": respondents,
285
  "likertAnswers": likert_answers,
286
+ "categoricalAnswers": categorical_answers,
287
  "openAnswers": open_answers,
288
  "regionStats": build_region_stats(config, respondents, likert_answers, open_answers, questions),
289
+ "questionStats": build_question_stats(questions, likert_answers, categorical_answers),
290
  "primaryQuestionId": primary_question_id,
291
  "llmTrace": {
292
  "engine": "openai_chat_completions" if isinstance(llm, OpenAILLM) else "injected_llm",
 
362
  return pool[:target_size]
363
 
364
 
365
+ def build_question_stats(questions: list[dict[str, Any]], likert_answers: list[dict[str, Any]], categorical_answers: list[dict[str, Any]]) -> list[dict[str, Any]]:
366
  stats: list[dict[str, Any]] = []
367
  for question in questions:
368
  question_id = str(question.get("id"))
369
  if question.get("kind") == "open":
370
  stats.append({"questionId": question_id, "title": question.get("title", question_id), "kind": "open"})
371
  continue
372
+ if question.get("kind") == "categorical":
373
+ answers = [answer for answer in categorical_answers if answer.get("questionId") == question_id]
374
+ options = [item for item in question.get("options", []) if isinstance(item, dict)]
375
+ distribution = []
376
+ for option in options:
377
+ option_id = str(option.get("id"))
378
+ count = len([answer for answer in answers if answer.get("optionId") == option_id])
379
+ distribution.append(
380
+ {
381
+ "optionId": option_id,
382
+ "label": str(option.get("label") or option_id),
383
+ "count": count,
384
+ "share": count / len(answers) if answers else 0,
385
+ }
386
+ )
387
+ stats.append({"questionId": question_id, "title": question.get("title", question_id), "kind": "categorical", "distribution": distribution})
388
+ continue
389
  scale = int(question.get("scale") or 5)
390
  values = [int(answer["value"]) for answer in likert_answers if answer.get("questionId") == question_id]
391
  distribution = [{"value": value, "count": values.count(value), "share": values.count(value) / len(values) if values else 0} for value in range(1, scale + 1)]
 
405
 
406
 
407
  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]]:
408
+ primary = next((question for question in questions if question.get("kind") == "likert"), None)
409
  primary_id = str(primary.get("id")) if primary else None
410
  scale = int(primary.get("scale") or 5) if primary else 5
411
  positive_cut = max(3, math.ceil(scale * 0.7))
silicon_logs.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ APP_DIR = Path(__file__).resolve().parent
13
+
14
+
15
+ def default_log_path() -> Path:
16
+ configured = os.getenv("SILICON_LOG_DB", "").strip()
17
+ if configured:
18
+ return Path(configured).expanduser()
19
+ data_dir = Path("/data")
20
+ if data_dir.exists() and os.access(data_dir, os.W_OK):
21
+ return data_dir / "silicon_logs.sqlite3"
22
+ return APP_DIR / "runs" / "silicon_logs.sqlite3"
23
+
24
+
25
+ def log_status() -> dict[str, Any]:
26
+ path = default_log_path()
27
+ return {
28
+ "enabled": True,
29
+ "path": str(path),
30
+ "exists": path.exists(),
31
+ "storage": "persistent_bucket" if str(path).startswith("/data/") else "local_ephemeral",
32
+ "runCount": count_runs(path) if path.exists() else 0,
33
+ }
34
+
35
+
36
+ def record_run(payload: dict[str, Any], result: dict[str, Any], client: dict[str, Any] | None = None) -> dict[str, Any]:
37
+ path = default_log_path()
38
+ ensure_schema(path)
39
+ run_id = f"run_{uuid.uuid4().hex[:16]}"
40
+ created_at = datetime.now(timezone.utc).isoformat()
41
+ config = payload.get("config", {}) if isinstance(payload.get("config"), dict) else {}
42
+ result_summary = build_result_summary(result)
43
+ with sqlite3.connect(path) as conn:
44
+ conn.execute(
45
+ """
46
+ INSERT INTO runs(
47
+ run_id,
48
+ created_at,
49
+ sample_size,
50
+ respondent_settings_json,
51
+ questions_json,
52
+ result_summary_json,
53
+ client_json
54
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
55
+ """,
56
+ (
57
+ run_id,
58
+ created_at,
59
+ int(config.get("sampleSize") or len(result.get("respondents", [])) or 0),
60
+ json.dumps(build_respondent_settings(config), ensure_ascii=False, sort_keys=True),
61
+ json.dumps(build_question_log(config), ensure_ascii=False, sort_keys=True),
62
+ json.dumps(result_summary, ensure_ascii=False, sort_keys=True),
63
+ json.dumps(client or {}, ensure_ascii=False, sort_keys=True),
64
+ ),
65
+ )
66
+ return {"runId": run_id, "createdAt": created_at, "storage": log_status()["storage"], "path": str(path)}
67
+
68
+
69
+ def recent_runs(limit: int = 20) -> list[dict[str, Any]]:
70
+ path = default_log_path()
71
+ if not path.exists():
72
+ return []
73
+ ensure_schema(path)
74
+ with sqlite3.connect(path) as conn:
75
+ conn.row_factory = sqlite3.Row
76
+ rows = conn.execute(
77
+ """
78
+ SELECT run_id, created_at, sample_size, respondent_settings_json, questions_json, result_summary_json
79
+ FROM runs
80
+ ORDER BY created_at DESC
81
+ LIMIT ?
82
+ """,
83
+ (max(1, min(100, int(limit))),),
84
+ ).fetchall()
85
+ return [
86
+ {
87
+ "runId": row["run_id"],
88
+ "createdAt": row["created_at"],
89
+ "sampleSize": row["sample_size"],
90
+ "respondentSettings": parse_json(row["respondent_settings_json"]),
91
+ "questions": parse_json(row["questions_json"]),
92
+ "resultSummary": parse_json(row["result_summary_json"]),
93
+ }
94
+ for row in rows
95
+ ]
96
+
97
+
98
+ def ensure_schema(path: Path) -> None:
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+ with sqlite3.connect(path) as conn:
101
+ conn.execute(
102
+ """
103
+ CREATE TABLE IF NOT EXISTS runs(
104
+ run_id TEXT PRIMARY KEY,
105
+ created_at TEXT NOT NULL,
106
+ sample_size INTEGER NOT NULL,
107
+ respondent_settings_json TEXT NOT NULL,
108
+ questions_json TEXT NOT NULL,
109
+ result_summary_json TEXT NOT NULL,
110
+ client_json TEXT NOT NULL
111
+ )
112
+ """
113
+ )
114
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_runs_created_at ON runs(created_at)")
115
+
116
+
117
+ def count_runs(path: Path) -> int:
118
+ try:
119
+ ensure_schema(path)
120
+ with sqlite3.connect(path) as conn:
121
+ return int(conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0])
122
+ except sqlite3.Error:
123
+ return 0
124
+
125
+
126
+ def build_respondent_settings(config: dict[str, Any]) -> dict[str, Any]:
127
+ return {
128
+ "sampleSize": int(config.get("sampleSize") or 0),
129
+ "genders": enabled_weighted(config.get("genders")),
130
+ "ages": enabled_weighted(config.get("ages")),
131
+ "locations": enabled_locations(config),
132
+ "personaAttributes": enabled_weighted(config.get("personaAttributes")),
133
+ "nemotronFields": [str(item) for item in config.get("nemotronFields", []) if item],
134
+ "seed": config.get("seed"),
135
+ }
136
+
137
+
138
+ def build_question_log(config: dict[str, Any]) -> list[dict[str, Any]]:
139
+ questions = config.get("questions", []) if isinstance(config.get("questions"), list) else []
140
+ logged: list[dict[str, Any]] = []
141
+ for question in questions:
142
+ if not isinstance(question, dict):
143
+ continue
144
+ logged.append(
145
+ {
146
+ "id": question.get("id"),
147
+ "title": question.get("title"),
148
+ "source": question.get("source"),
149
+ "category": question.get("category"),
150
+ "kind": question.get("kind"),
151
+ "scale": question.get("scale"),
152
+ "lowLabel": question.get("lowLabel"),
153
+ "highLabel": question.get("highLabel"),
154
+ "options": question.get("options") if question.get("kind") == "categorical" else None,
155
+ }
156
+ )
157
+ return logged
158
+
159
+
160
+ def build_result_summary(result: dict[str, Any]) -> dict[str, Any]:
161
+ return {
162
+ "respondents": len(result.get("respondents", [])),
163
+ "likertAnswers": len(result.get("likertAnswers", [])),
164
+ "categoricalAnswers": len(result.get("categoricalAnswers", [])),
165
+ "openAnswers": len(result.get("openAnswers", [])),
166
+ "primaryQuestionId": result.get("primaryQuestionId"),
167
+ "questionStats": result.get("questionStats", []),
168
+ "regionStats": result.get("regionStats", []),
169
+ "llmTrace": {
170
+ key: value
171
+ for key, value in (result.get("llmTrace", {}) if isinstance(result.get("llmTrace"), dict) else {}).items()
172
+ if key not in {"rawResponses"}
173
+ },
174
+ }
175
+
176
+
177
+ def enabled_weighted(items: Any) -> list[dict[str, Any]]:
178
+ return [
179
+ {"id": str(item.get("id")), "weight": item.get("weight")}
180
+ for item in items or []
181
+ if isinstance(item, dict) and item.get("enabled")
182
+ ]
183
+
184
+
185
+ def enabled_locations(config: dict[str, Any]) -> list[dict[str, Any]]:
186
+ labels = {
187
+ str(item.get("id")): {
188
+ "label": item.get("label"),
189
+ "parentRegion": item.get("parentRegion"),
190
+ "level": item.get("level"),
191
+ }
192
+ for item in config.get("locationOptions", [])
193
+ if isinstance(item, dict)
194
+ }
195
+ rows = []
196
+ for item in config.get("locations", []) or []:
197
+ if not isinstance(item, dict) or not item.get("enabled"):
198
+ continue
199
+ location_id = str(item.get("id"))
200
+ rows.append({"id": location_id, "weight": item.get("weight"), **labels.get(location_id, {})})
201
+ return rows
202
+
203
+
204
+ def parse_json(value: str) -> Any:
205
+ try:
206
+ return json.loads(value)
207
+ except json.JSONDecodeError:
208
+ return None