Spaces:
Sleeping
Sleeping
Add categorical survey questions and run logging
Browse files- frontend/src/silicon/SiliconCharts.tsx +56 -5
- frontend/src/silicon/SiliconSamplingApp.tsx +55 -5
- frontend/src/silicon/simulate.ts +37 -2
- frontend/src/silicon/types.ts +12 -2
- frontend/src/styles.css +46 -0
- scripts/test_silicon_llm_runtime.py +23 -4
- scripts/test_silicon_logs.py +73 -0
- scripts/validate_silicon_llm_api.py +53 -6
- server.py +24 -2
- silicon_llm.py +55 -5
- silicon_logs.py +208 -0
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 ==
|
| 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
|
| 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"]) ==
|
| 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=
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
| 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")
|
| 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
|