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