qqyule commited on
Commit
dd6cefc
·
verified ·
1 Parent(s): 72dc154

Deploy latest Objectverse Diary from fa09aac

Browse files
.playwright-cli/console-2026-06-08T11-55-41-257Z.log ADDED
@@ -0,0 +1 @@
 
 
1
+ [ 101248ms] [ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://127.0.0.1:7861/gradio_api/heartbeat/ybhxcw7jgrp:0
data/train/objectverse_sft_curated_v2.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
docs/07-development-plan.md CHANGED
@@ -143,7 +143,7 @@ Verification:
143
 
144
  Goal: make persona, diary, and chat generation use a small local text model runtime.
145
 
146
- Status: optional runtime wiring complete; real GGUF smoke test pending.
147
 
148
  Scope:
149
 
@@ -158,12 +158,12 @@ Scope:
158
  Exit criteria:
159
 
160
  - Text generation can run through llama.cpp or documented local fallback.
161
- - README documents runtime path. Final model size remains pending until GGUF selection.
162
  - Trace records include runtime metadata.
163
 
164
  Verification:
165
 
166
- - Local runtime smoke test with a real GGUF.
167
  - JSON schema validation.
168
  - Compare at least three object generations for persona consistency.
169
 
 
143
 
144
  Goal: make persona, diary, and chat generation use a small local text model runtime.
145
 
146
+ Status: optional runtime wiring complete; published LoRA v2 Q4_K_M GGUF passed local llama.cpp smoke. Hosted Space text runtime validation is still pending.
147
 
148
  Scope:
149
 
 
158
  Exit criteria:
159
 
160
  - Text generation can run through llama.cpp or documented local fallback.
161
+ - README documents runtime path and published GGUF selection.
162
  - Trace records include runtime metadata.
163
 
164
  Verification:
165
 
166
+ - Local runtime smoke test with `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`.
167
  - JSON schema validation.
168
  - Compare at least three object generations for persona consistency.
169
 
docs/DATASET.md CHANGED
@@ -4,7 +4,7 @@
4
 
5
  The project now has a deterministic SFT preview generator for local planning and schema validation.
6
 
7
- Current local artifact:
8
 
9
  ```bash
10
  .venv/bin/python -B scripts/generate_dataset.py
@@ -18,9 +18,9 @@ data/train/objectverse_sft_preview.jsonl
18
 
19
  This preview is mock-generated. It is not a final training dataset and should not be described as real model output.
20
 
21
- The stable submission baseline does not publish a final Hugging Face Dataset. The current JSONL file is evidence for schema and workflow readiness only.
22
 
23
- Additional local training-test artifact:
24
 
25
  ```bash
26
  .venv/bin/python -B scripts/prepare_curated_dataset.py \
@@ -36,11 +36,22 @@ Published synthetic curated dataset:
36
  https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
37
  ```
38
 
 
 
 
 
 
 
 
 
 
 
 
39
  ## Target Dataset
40
 
41
- Final target before fine-tuning:
42
 
43
- - 200-500 generated object-persona-diary samples
44
  - at least 50 manually curated high-quality samples
45
  - no private user photos
46
  - no emails, tokens, serial numbers, or other sensitive identifiers
@@ -82,7 +93,7 @@ Full candidate pool later:
82
  .venv/bin/python -B scripts/generate_dataset.py --count 300 --output data/train/objectverse_sft_candidates.jsonl
83
  ```
84
 
85
- Manual curation should happen after generation. Do not publish the full candidate file until it has been reviewed.
86
 
87
  Space VLM validation traces under `data/traces/space-vlm/` are failure evidence because they include `vision-fallback-to-mock`. Do not mix them into curated training data or describe them as successful real VLM outputs.
88
 
@@ -105,27 +116,77 @@ Validate the local JSONL shape without Modal auth or GPU usage:
105
  --run-name objectverse-diary-qwen15b-curated-test
106
  ```
107
 
108
- Intended training command after explicit confirmation:
109
 
110
  ```bash
111
- modal run scripts/finetune_lora.py \
112
- --dataset data/train/objectverse_sft_curated.jsonl \
113
- --run-name objectverse-diary-qwen15b-curated-test \
114
- --max-steps 20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  ```
116
 
117
- Current Modal status: the curated test job completed successfully and produced the published LoRA adapter at `https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  Default training scaffold settings:
120
 
121
  - base model: `Qwen/Qwen2.5-1.5B-Instruct`
122
  - LoRA adapter target: persona and diary JSON output
 
 
123
  - GPU: Modal `A10G`
124
  - output: Modal Volume artifacts, not committed files
125
 
126
  The current `objectverse_sft_preview.jsonl` file is mock-generated and should only be used to validate the training pipeline. It is not final Well-Tuned evidence. Do not store Modal credit codes, tokens, Hugging Face tokens, or private datasets in the repo.
127
 
128
- The published `objectverse_sft_curated.jsonl` dataset is synthetic curated training-test data. It is suitable for hackathon training evidence, but it should still be described honestly as a small synthetic set rather than real user trace data.
129
 
130
  ## Curation Checklist
131
 
@@ -139,10 +200,20 @@ The published `objectverse_sft_curated.jsonl` dataset is synthetic curated train
139
 
140
  ## Publishing Notes
141
 
142
- Before publishing to Hugging Face Datasets:
143
 
144
  - create a dataset card
145
  - document that mock preview rows are synthetic
146
  - separate curated rows from raw candidates
147
  - include license and privacy notes
148
  - keep private images out of the repo
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  The project now has a deterministic SFT preview generator for local planning and schema validation.
6
 
7
+ Current preview artifact:
8
 
9
  ```bash
10
  .venv/bin/python -B scripts/generate_dataset.py
 
18
 
19
  This preview is mock-generated. It is not a final training dataset and should not be described as real model output.
20
 
21
+ The preview JSONL file is evidence for schema and workflow readiness only.
22
 
23
+ Curated v1 training-test artifact:
24
 
25
  ```bash
26
  .venv/bin/python -B scripts/prepare_curated_dataset.py \
 
36
  https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
37
  ```
38
 
39
+ Current curated v2 artifact:
40
+
41
+ ```bash
42
+ .venv/bin/python -B scripts/prepare_curated_dataset.py \
43
+ --version v2 \
44
+ --count 200 \
45
+ --output data/train/objectverse_sft_curated_v2.jsonl
46
+ ```
47
+
48
+ The published dataset repo now includes `objectverse_sft_curated_v2.jsonl`: 200 synthetic curated rows covering 40 everyday objects and 5 personality modes, with exactly 40 rows per mode and no repeated object-mode pair. The v1 file remains preserved through repository history.
49
+
50
  ## Target Dataset
51
 
52
+ Target before stronger fine-tuning:
53
 
54
+ - 200-500 generated or curated object-persona-diary samples
55
  - at least 50 manually curated high-quality samples
56
  - no private user photos
57
  - no emails, tokens, serial numbers, or other sensitive identifiers
 
93
  .venv/bin/python -B scripts/generate_dataset.py --count 300 --output data/train/objectverse_sft_candidates.jsonl
94
  ```
95
 
96
+ Manual curation should happen after generation. For a stronger LoRA run, curate 150-300 rows from a broader object/mode/scene pool and leave 10-15% for evaluation. Do not publish the full candidate file until it has been reviewed.
97
 
98
  Space VLM validation traces under `data/traces/space-vlm/` are failure evidence because they include `vision-fallback-to-mock`. Do not mix them into curated training data or describe them as successful real VLM outputs.
99
 
 
116
  --run-name objectverse-diary-qwen15b-curated-test
117
  ```
118
 
119
+ The first badge-evidence run used 20 steps on 50 synthetic curated rows. For a higher-quality v2 run, validate the larger curated file first:
120
 
121
  ```bash
122
+ .venv/bin/python -B scripts/finetune_lora.py \
123
+ --dry-run \
124
+ --dataset data/train/objectverse_sft_curated_v2.jsonl \
125
+ --run-name objectverse-diary-qwen15b-lora-v2 \
126
+ --max-steps 120 \
127
+ --learning-rate 1e-4 \
128
+ --max-seq-length 1536 \
129
+ --lora-r 32 \
130
+ --lora-alpha 64 \
131
+ --per-device-train-batch-size 2 \
132
+ --gradient-accumulation-steps 4 \
133
+ --eval-ratio 0.1 \
134
+ --eval-steps 20
135
+ ```
136
+
137
+ Executed v2 training command:
138
+
139
+ ```bash
140
+ modal run --timestamps -n objectverse-diary-qwen15b-lora-v2 scripts/finetune_lora.py \
141
+ --dataset data/train/objectverse_sft_curated_v2.jsonl \
142
+ --run-name objectverse-diary-qwen15b-lora-v2 \
143
+ --max-steps 120 \
144
+ --learning-rate 1e-4 \
145
+ --max-seq-length 1536 \
146
+ --lora-r 32 \
147
+ --lora-alpha 64 \
148
+ --per-device-train-batch-size 2 \
149
+ --gradient-accumulation-steps 4 \
150
+ --eval-ratio 0.1 \
151
+ --eval-steps 20
152
  ```
153
 
154
+ Current Modal status: the v2 job completed successfully and produced the published LoRA adapter at `https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora`.
155
+
156
+ Current v2 run summary:
157
+
158
+ - run name: `objectverse-diary-qwen15b-lora-v2`
159
+ - dataset: `data/train/objectverse_sft_curated_v2.jsonl`
160
+ - dataset repo path: `objectverse_sft_curated_v2.jsonl`
161
+ - records: 200 total, 180 train, 20 eval
162
+ - base model: `Qwen/Qwen2.5-1.5B-Instruct`
163
+ - max steps: 120
164
+ - learning rate: `1e-4`
165
+ - max sequence length: 1536
166
+ - LoRA rank / alpha / dropout: 32 / 64 / 0.05
167
+ - effective batch size: 8
168
+ - assistant-output-only loss: enabled
169
+ - train loss: 0.3240
170
+ - eval loss: 0.0162
171
+ - train runtime: 140.3364s
172
+ - epoch: 5.2222
173
+ - local adapter export: ignored `exports/objectverse-diary-qwen15b-lora-v2-adapter-dir/`
174
+ - model repo: `https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora`
175
+
176
+ Additional v2 scaffold validation run: `objectverse-diary-qwen15b-lora-v2-curated50-retry1` completed on Modal with the existing 50-row curated dataset, using assistant-output-only loss, 45 train rows, 5 eval rows, `max_steps=120`, `learning_rate=1e-4`, `max_seq_length=1536`, LoRA `r=32`, `alpha=64`, and effective batch size 8. Metrics: `train_loss=0.2551`, `eval_loss=0.0093`, `train_runtime=146.5398s`, `epoch=20.0`. The adapter was downloaded to ignored local `exports/`; it has not been published to Hugging Face Hub.
177
 
178
  Default training scaffold settings:
179
 
180
  - base model: `Qwen/Qwen2.5-1.5B-Instruct`
181
  - LoRA adapter target: persona and diary JSON output
182
+ - default loss: assistant-output-only labels, with prompt tokens masked
183
+ - default eval split: 10% when the dataset has at least two rows
184
  - GPU: Modal `A10G`
185
  - output: Modal Volume artifacts, not committed files
186
 
187
  The current `objectverse_sft_preview.jsonl` file is mock-generated and should only be used to validate the training pipeline. It is not final Well-Tuned evidence. Do not store Modal credit codes, tokens, Hugging Face tokens, or private datasets in the repo.
188
 
189
+ The published `objectverse_sft_curated_v2.jsonl` dataset is synthetic curated training data. It is suitable for hackathon training evidence, but it should still be described honestly as deterministic synthetic curation rather than real user trace data.
190
 
191
  ## Curation Checklist
192
 
 
200
 
201
  ## Publishing Notes
202
 
203
+ When publishing to Hugging Face Datasets:
204
 
205
  - create a dataset card
206
  - document that mock preview rows are synthetic
207
  - separate curated rows from raw candidates
208
  - include license and privacy notes
209
  - keep private images out of the repo
210
+
211
+ Curated v2 was published with:
212
+
213
+ ```bash
214
+ .venv/bin/python -B scripts/publish_hf_dataset.py \
215
+ --dataset-file data/train/objectverse_sft_curated_v2.jsonl \
216
+ --repo-id qqyule/objectverse-diary-sft-curated \
217
+ --path-in-repo objectverse_sft_curated_v2.jsonl \
218
+ --commit-message "Upload Objectverse Diary curated v2 dataset"
219
+ ```
docs/DEMO_VIDEO_SCRIPT.md CHANGED
@@ -4,7 +4,7 @@
4
 
5
  Record a 90-second stable demo for Objectverse Diary using the mock-safe Hugging Face Space or local Gradio app.
6
 
7
- Do not claim that GGUF text generation or live LoRA runtime wiring are complete. Hosted MiniCPM-V validation is complete for the vision path, but the stable demo should still emphasize the mock-safe product loop, Gradio Off-Brand UI, public traces, published dataset/LoRA evidence, and no commercial AI APIs.
8
 
9
  ## Recording Setup
10
 
@@ -104,6 +104,6 @@ Screen:
104
  ## Notes For Submission
105
 
106
  - Mention MiniCPM-V as hosted-validated for object understanding, while the public demo defaults to mock for reliability.
107
- - Mention the published synthetic curated dataset and LoRA adapter only as training evidence, not live Space runtime.
108
  - Mention public traces and failure notes if the submission form asks for reproducibility.
109
  - Keep the final video under 2 minutes.
 
4
 
5
  Record a 90-second stable demo for Objectverse Diary using the mock-safe Hugging Face Space or local Gradio app.
6
 
7
+ Do not claim that live Space LoRA/GGUF runtime wiring is complete. Hosted MiniCPM-V validation is complete for the vision path and local GGUF text smoke is complete, but the stable demo should still emphasize the mock-safe product loop, Gradio Off-Brand UI, public traces, published dataset/model evidence, and no commercial AI APIs.
8
 
9
  ## Recording Setup
10
 
 
104
  ## Notes For Submission
105
 
106
  - Mention MiniCPM-V as hosted-validated for object understanding, while the public demo defaults to mock for reliability.
107
+ - Mention the published synthetic curated dataset, LoRA adapter, and Q4_K_M GGUF as model evidence, not live Space runtime.
108
  - Mention public traces and failure notes if the submission form asks for reproducibility.
109
  - Keep the final video under 2 minutes.
docs/DEVELOPMENT_STATUS.md CHANGED
@@ -38,21 +38,22 @@ Last updated: 2026-06-08
38
  - social post draft
39
  - stable submission guide
40
  - Well-Tuned evidence:
41
- - 50-row synthetic curated SFT dataset published at https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
42
- - Modal Qwen 1.5B LoRA test run completed with 20 steps
43
- - LoRA adapter published at https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
44
- - GGUF smoke-test helper:
45
  - `scripts/check_llama_cpp_smoke.py`
46
- - recommended baseline model documented as `Qwen/Qwen2.5-1.5B-Instruct-GGUF` / `qwen2.5-1.5b-instruct-q4_k_m.gguf`
 
 
 
47
  - trace runtime no longer records literal `TEXT_MODEL_PATH`
48
  - Local tests and initial acceptance currently pass.
49
 
50
  ## Not Completed
51
 
52
- - Real GGUF download/configuration outside Git and `TEXT_MODEL_PATH` smoke test. Model selection is now documented, but the file is not downloaded and optional `llama-cpp-python` is not installed by default.
53
- - Final text model parameter count documentation.
54
- - Real text model traces from non-mock runtime.
55
- - GGUF conversion and runtime wiring for the published LoRA adapter.
56
  - Published Field Notes URL, recorded demo video URL, social post URL, and final public submission.
57
 
58
  ## Current Safe Defaults
@@ -68,6 +69,15 @@ For a stable public baseline, keep the mock-safe Space as the demo path and only
68
 
69
  Next model gate:
70
 
 
 
 
 
 
 
 
 
 
71
  Optional rerun gate if Space variables, secrets, or dependencies change:
72
 
73
  ```bash
 
38
  - social post draft
39
  - stable submission guide
40
  - Well-Tuned evidence:
41
+ - 200-row synthetic curated v2 SFT dataset published at https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
42
+ - Modal Qwen 1.5B LoRA v2 run completed with 120 steps, 180 train rows, and 20 eval rows
43
+ - LoRA v2 adapter published at https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
44
+ - LoRA v2 GGUF runtime evidence:
45
  - `scripts/check_llama_cpp_smoke.py`
46
+ - adapter merged into `Qwen/Qwen2.5-1.5B-Instruct`
47
+ - pinned `llama.cpp` commit: `8f83d6c271d194bde2d410145a0ce73bc42e85cd`
48
+ - published Q4_K_M GGUF: https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora/blob/main/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf
49
+ - local smoke passed with `llama-cpp text generation`, schema-valid persona/diary, non-empty chat, and no `text-fallback-to-mock`
50
  - trace runtime no longer records literal `TEXT_MODEL_PATH`
51
  - Local tests and initial acceptance currently pass.
52
 
53
  ## Not Completed
54
 
55
+ - Hosted Space text runtime validation with the published GGUF. The public Space still uses mock-safe text until this passes.
56
+ - Real text model traces from the hosted non-mock text runtime.
 
 
57
  - Published Field Notes URL, recorded demo video URL, social post URL, and final public submission.
58
 
59
  ## Current Safe Defaults
 
69
 
70
  Next model gate:
71
 
72
+ Download or mount the published GGUF on the target runtime, set:
73
+
74
+ ```bash
75
+ OBJECTVERSE_TEXT_BACKEND=llama-cpp
76
+ TEXT_MODEL_PATH=/absolute/path/to/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf
77
+ ```
78
+
79
+ Then rerun the local or Space smoke path before claiming live text runtime.
80
+
81
  Optional rerun gate if Space variables, secrets, or dependencies change:
82
 
83
  ```bash
docs/FAILURES.md CHANGED
@@ -12,10 +12,10 @@ MiniCPM-V 2.6 is wired as an optional vision backend. Hosted Space ZeroGPU valid
12
 
13
  The app includes a hidden `/vision_runtime_probe` API and `scripts/check_space_vlm.py` writes probe output into the Space VLM report before image validation. This probe identified the previous failure as a gated-model access issue rather than a GPU or dependency issue.
14
 
15
- The recommended baseline GGUF for local text smoke testing is selected, but not downloaded or run:
16
 
17
- - repo: `Qwen/Qwen2.5-1.5B-Instruct-GGUF`
18
- - file: `qwen2.5-1.5b-instruct-q4_k_m.gguf`
19
  - helper: `scripts/check_llama_cpp_smoke.py`
20
 
21
  Known non-blocking warning:
@@ -70,15 +70,27 @@ Known non-blocking warning:
70
  - Fallback used: mock object understanding plus mock text runtime if validation reaches generation.
71
  - Resolution: unresolved; keep the public Space mock-safe until this section reports a passing VLM validation.
72
 
73
- ## 2026-06-08 - GGUF Smoke Helper Prepared, Actual Smoke Pending
74
 
75
  - Area: llama.cpp text runtime evidence.
76
- - Reproduction: Run `scripts/check_llama_cpp_smoke.py` with an external GGUF model path after optional dependency installation.
77
  - Expected: trace records `llama-cpp text generation`, persona/diary/chat run without `text-fallback-to-mock`.
78
- - Actual: not run; `.venv` does not include `llama-cpp-python` by default and the GGUF file is intentionally not committed.
79
- - Impact: Llama Champion evidence remains incomplete.
80
- - Fallback used: default mock text runtime remains the safe public demo path.
81
- - Resolution: pending explicit confirmation to install optional local dependency and download `qwen2.5-1.5b-instruct-q4_k_m.gguf` into ignored `models/`.
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  ## Anticipated Failure Areas
84
 
 
12
 
13
  The app includes a hidden `/vision_runtime_probe` API and `scripts/check_space_vlm.py` writes probe output into the Space VLM report before image validation. This probe identified the previous failure as a gated-model access issue rather than a GPU or dependency issue.
14
 
15
+ The published LoRA v2 GGUF for local text smoke testing is available and has passed local llama.cpp smoke:
16
 
17
+ - repo: `qqyule/objectverse-diary-qwen15b-lora`
18
+ - file: `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`
19
  - helper: `scripts/check_llama_cpp_smoke.py`
20
 
21
  Known non-blocking warning:
 
70
  - Fallback used: mock object understanding plus mock text runtime if validation reaches generation.
71
  - Resolution: unresolved; keep the public Space mock-safe until this section reports a passing VLM validation.
72
 
73
+ ## 2026-06-08 - LoRA v2 GGUF Local Smoke Passed
74
 
75
  - Area: llama.cpp text runtime evidence.
76
+ - Reproduction: Run `scripts/check_llama_cpp_smoke.py` with `models/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf` after optional `llama-cpp-python` installation.
77
  - Expected: trace records `llama-cpp text generation`, persona/diary/chat run without `text-fallback-to-mock`.
78
+ - Actual: passed locally; trace included only `mock-vision-runtime` because text was real and vision remained mock for the smoke input.
79
+ - Impact: local llama.cpp text runtime evidence is ready. Public Space text runtime is still not validated with this GGUF.
80
+ - Fallback used: none for text.
81
+ - Resolution: resolved locally by using the merged LoRA v2 Q4_K_M GGUF and conservative JSON extraction / decoding settings.
82
+ - Evidence: `scripts/check_llama_cpp_smoke.py`, `docs/RUNTIME.md`, and the Hub file in `qqyule/objectverse-diary-qwen15b-lora`.
83
+
84
+ ## 2026-06-08 - Hugging Face Xet GGUF Upload Stalled
85
+
86
+ - Area: Hugging Face model file upload.
87
+ - Reproduction: Upload `models/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf` with the default Hub client path.
88
+ - Expected: upload completes and commits `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`.
89
+ - Actual: the first upload stalled with Xet TLS EOF / `CLOSE_WAIT` after partial progress.
90
+ - Impact: upload needed a retry; local GGUF file was unaffected.
91
+ - Fallback used: stopped the stalled upload process and retried with `HF_HUB_DISABLE_XET=1`.
92
+ - Resolution: resolved; ordinary Hub/LFS upload succeeded.
93
+ - Evidence: Hub file `https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora/blob/main/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`.
94
 
95
  ## Anticipated Failure Areas
96
 
docs/FIELD_NOTES.md CHANGED
@@ -137,10 +137,10 @@ For text runtime evidence, the project now includes a local smoke helper for an
137
 
138
  ```bash
139
  .venv/bin/python -B scripts/check_llama_cpp_smoke.py \
140
- --model-path models/qwen2.5-1.5b-instruct-q4_k_m.gguf
141
  ```
142
 
143
- The recommended baseline file is `qwen2.5-1.5b-instruct-q4_k_m.gguf` from `Qwen/Qwen2.5-1.5B-Instruct-GGUF`. It is intentionally not committed.
144
 
145
  ## 10. Privacy And Safety
146
 
@@ -150,12 +150,12 @@ Trace logging anonymizes text inputs before public export. The current public tr
150
 
151
  ## 11. What I Would Improve Next
152
 
153
- The next model-focused step is to smoke-test a real GGUF text model through llama.cpp.
154
 
155
  After that:
156
 
157
- - run the documented GGUF smoke test after explicit confirmation
158
- - decide whether the published LoRA should remain badge evidence only or be converted later
159
  - generate real non-mock traces if hosted/local model validation passes
160
  - record a final demo video from the stable Space
161
 
 
137
 
138
  ```bash
139
  .venv/bin/python -B scripts/check_llama_cpp_smoke.py \
140
+ --model-path models/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf
141
  ```
142
 
143
+ The published local-smoke file is `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf` from `qqyule/objectverse-diary-qwen15b-lora`. It is intentionally not committed. Local smoke passed on June 8, 2026; Space text runtime still needs a separate validation before it should be described as live.
144
 
145
  ## 10. Privacy And Safety
146
 
 
150
 
151
  ## 11. What I Would Improve Next
152
 
153
+ The next model-focused step is to validate the published GGUF in the hosted Space runtime, or keep it as local llama.cpp evidence while the public demo remains mock-safe.
154
 
155
  After that:
156
 
157
+ - download or mount the published GGUF in the target runtime
158
+ - set `OBJECTVERSE_TEXT_BACKEND=llama-cpp` and `TEXT_MODEL_PATH` for that runtime
159
  - generate real non-mock traces if hosted/local model validation passes
160
  - record a final demo video from the stable Space
161
 
docs/FINAL_VERIFICATION_REPORT.md CHANGED
@@ -8,15 +8,16 @@
8
 
9
  ## Summary
10
 
11
- Objectverse Diary's stable mock-safe baseline remains locally verifiable. This update adds non-secret MiniCPM-V runtime diagnostics through a hidden Gradio API, probe-aware Space VLM reporting, a latest-failure-note updater, and a local llama.cpp GGUF smoke-test helper.
12
 
13
- This report does not claim real GGUF text generation, live LoRA runtime wiring, Field Notes publication, demo video publication, social post publication, or final public submission URLs are complete.
14
 
15
  ## Implementation Additions
16
 
17
  - Hidden `/vision_runtime_probe` Gradio API returns sanitized backend, dependency, GPU, and MiniCPM-V load diagnostics.
18
  - `scripts/check_space_vlm.py` can include probe output in markdown/JSON reports and update the latest failure section in `docs/FAILURES.md`.
19
  - `scripts/check_llama_cpp_smoke.py` validates persona, diary, and chat through an externally configured GGUF without committing model files.
 
20
  - Runtime status no longer records literal `TEXT_MODEL_PATH`; traces only record whether an external GGUF path is configured.
21
  - Submission docs now distinguish final-draft materials from published URLs.
22
 
@@ -88,11 +89,11 @@ No GGUF file, real token, private key, credential, or `.env` file was added by t
88
  - Demo video URL is still pending recording/publication.
89
  - Field Notes URL is still pending publication.
90
  - Social post URL is still pending publication.
91
- - Real GGUF download, optional `llama-cpp-python` installation, and smoke test remain pending explicit confirmation.
92
- - GGUF conversion and live runtime wiring for the published LoRA adapter remain future work.
93
 
94
  ## Verdict
95
 
96
  PASS for the stable mock-safe local submission baseline plus local diagnostics/smoke-helper implementation.
97
 
98
- The project is ready for explicit-confirmation external steps: push `main`, sync the Space, rerun probe-aware Space VLM validation, run the local GGUF smoke test after optional dependency/model setup, record/publish the demo video, publish Field Notes/social post, and fill final submission URLs.
 
8
 
9
  ## Summary
10
 
11
+ Objectverse Diary's stable mock-safe baseline remains locally verifiable. This update adds non-secret MiniCPM-V runtime diagnostics through a hidden Gradio API, probe-aware Space VLM reporting, a latest-failure-note updater, and local llama.cpp GGUF smoke-test support. A later local run merged the LoRA v2 adapter, produced a Q4_K_M GGUF, uploaded it to the model repo, and passed local llama.cpp smoke.
12
 
13
+ This report does not claim live Space LoRA/GGUF runtime wiring, Field Notes publication, demo video publication, social post publication, or final public submission URLs are complete.
14
 
15
  ## Implementation Additions
16
 
17
  - Hidden `/vision_runtime_probe` Gradio API returns sanitized backend, dependency, GPU, and MiniCPM-V load diagnostics.
18
  - `scripts/check_space_vlm.py` can include probe output in markdown/JSON reports and update the latest failure section in `docs/FAILURES.md`.
19
  - `scripts/check_llama_cpp_smoke.py` validates persona, diary, and chat through an externally configured GGUF without committing model files.
20
+ - LoRA v2 GGUF tooling now covers merge, publish, and local smoke for `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`.
21
  - Runtime status no longer records literal `TEXT_MODEL_PATH`; traces only record whether an external GGUF path is configured.
22
  - Submission docs now distinguish final-draft materials from published URLs.
23
 
 
89
  - Demo video URL is still pending recording/publication.
90
  - Field Notes URL is still pending publication.
91
  - Social post URL is still pending publication.
92
+ - Hosted Space text runtime validation with the published GGUF remains pending.
93
+ - Live Space runtime wiring for the published LoRA/GGUF remains future work.
94
 
95
  ## Verdict
96
 
97
  PASS for the stable mock-safe local submission baseline plus local diagnostics/smoke-helper implementation.
98
 
99
+ The project is ready for explicit-confirmation external steps: push `main`, sync the Space, rerun probe-aware Space VLM validation if needed, validate the published GGUF in the Space runtime before claiming live text generation, record/publish the demo video, publish Field Notes/social post, and fill final submission URLs.
docs/MODEL_CARD.md CHANGED
@@ -2,9 +2,9 @@
2
 
3
  ## Status
4
 
5
- Stable submission baseline plus one published text LoRA test adapter. The public Gradio Space still defaults to deterministic mock text; the adapter is training evidence and has not been converted to GGUF or wired into the live runtime.
6
 
7
- The app defaults to deterministic mock backends. MiniCPM-V 2.6 vision is wired as an optional runtime backend for GPU environments, with a hidden non-secret probe for hosted diagnostics. Text generation has optional llama.cpp wiring for an externally configured GGUF model via `TEXT_MODEL_PATH`. A Modal LoRA test run completed for the planned text model path and the adapter is published at `https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora`.
8
 
9
  Hosted MiniCPM-V validation passed on June 8, 2026 after adding an `HF_TOKEN` Space secret with access to the gated `openbmb/MiniCPM-V-2_6` model. The validation used public mug, keyboard, and shoe images on ZeroGPU, while text generation intentionally remained mock. See `docs/SPACE_VLM_REPORT.md`.
10
 
@@ -19,8 +19,8 @@ Hosted MiniCPM-V validation passed on June 8, 2026 after adding an `HF_TOKEN` Sp
19
  | Component | Candidate | Notes |
20
  | --- | --- | --- |
21
  | Vision | `openbmb/MiniCPM-V-2_6` or mock fallback | Wired as optional backend; hosted ZeroGPU validation passed, then Space rolled back to mock-safe defaults. |
22
- | Text | deterministic mock text; published `Qwen/Qwen2.5-1.5B-Instruct` LoRA test adapter | Adapter published; not converted to GGUF or wired into Space runtime. |
23
- | Runtime | optional GGUF through llama.cpp / llama-cpp-python | Wired with mock fallback; smoke helper exists, real-model smoke test still pending. |
24
  | UI | Gradio Blocks | Required by the hackathon and project rules. |
25
 
26
  ## Parameter Budget
@@ -34,7 +34,7 @@ Record final numbers here before submission:
34
  | Vision | MiniCPM-V 2.6 optional path | ~8B | yes, when enabled |
35
  | Text base | Stable baseline mock text | 0 | no model parameters |
36
  | Optional text base | `Qwen/Qwen2.5-1.5B-Instruct` | ~1.5B | yes, when enabled |
37
- | Recommended GGUF smoke file | `Qwen/Qwen2.5-1.5B-Instruct-GGUF` / `qwen2.5-1.5b-instruct-q4_k_m.gguf` | ~1.5B base, quantized file | yes, if used for text runtime smoke |
38
  | Published LoRA adapter | `qqyule/objectverse-diary-qwen15b-lora` | small adapter over base model | yes, when enabled |
39
  | Stable baseline total | Mock text + optional wired vision not active by default | 0 active model parameters by default | <= 32B |
40
 
@@ -63,7 +63,7 @@ Dataset planning lives in `docs/DATASET.md`.
63
 
64
  Current preview data is deterministic and mock-generated. It should only be used for schema validation, dry-run validation, and workflow planning until real candidate samples are generated and curated.
65
 
66
- The Modal training scaffold defaults to `Qwen/Qwen2.5-1.5B-Instruct` and saves adapter artifacts to a Modal Volume. `data/train/objectverse_sft_curated.jsonl` contains 50 synthetic curated rows for pipeline testing and is published at `https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated`.
67
 
68
  Published adapter:
69
 
@@ -71,25 +71,32 @@ Published adapter:
71
  https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
72
  ```
73
 
74
- Training run summary:
75
 
76
  - Platform: Modal
77
- - Run name: `objectverse-diary-qwen15b-curated-test`
78
  - Base model: `Qwen/Qwen2.5-1.5B-Instruct`
79
- - Dataset: 50 synthetic curated rows
80
- - Steps: 20
81
- - Max sequence length: 1024
82
- - Learning rate: 0.0002
83
- - LoRA rank / alpha / dropout: 16 / 32 / 0.05
84
- - Train loss: 1.6697
85
- - GGUF conversion: not completed
 
 
 
 
 
 
86
 
87
  GGUF smoke status:
88
 
89
- - Recommended repo: `Qwen/Qwen2.5-1.5B-Instruct-GGUF`
90
- - Recommended file: `qwen2.5-1.5b-instruct-q4_k_m.gguf`
91
  - Local helper: `scripts/check_llama_cpp_smoke.py`
92
- - Current state: file not downloaded, optional `llama-cpp-python` not installed by default, smoke test not run.
 
93
 
94
  ## Safety And Privacy
95
 
 
2
 
3
  ## Status
4
 
5
+ Stable submission baseline plus one published text LoRA v2 adapter and one published Q4_K_M GGUF. The public Gradio Space still defaults to deterministic mock text; the GGUF has passed local llama.cpp smoke, but it has not been switched into the live Space runtime.
6
 
7
+ The app defaults to deterministic mock backends. MiniCPM-V 2.6 vision is wired as an optional runtime backend for GPU environments, with a hidden non-secret probe for hosted diagnostics. Text generation has optional llama.cpp wiring for an externally configured GGUF model via `TEXT_MODEL_PATH`. A Modal LoRA v2 run completed, the adapter is published at `https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora`, and the merged Q4_K_M GGUF is published in the same repo.
8
 
9
  Hosted MiniCPM-V validation passed on June 8, 2026 after adding an `HF_TOKEN` Space secret with access to the gated `openbmb/MiniCPM-V-2_6` model. The validation used public mug, keyboard, and shoe images on ZeroGPU, while text generation intentionally remained mock. See `docs/SPACE_VLM_REPORT.md`.
10
 
 
19
  | Component | Candidate | Notes |
20
  | --- | --- | --- |
21
  | Vision | `openbmb/MiniCPM-V-2_6` or mock fallback | Wired as optional backend; hosted ZeroGPU validation passed, then Space rolled back to mock-safe defaults. |
22
+ | Text | deterministic mock text by default; published `Qwen/Qwen2.5-1.5B-Instruct` LoRA v2 Q4_K_M GGUF for local runtime | Adapter and GGUF published; Space text runtime remains mock-safe. |
23
+ | Runtime | optional GGUF through llama.cpp / llama-cpp-python | Wired with mock fallback; local GGUF smoke passed on 2026-06-08. |
24
  | UI | Gradio Blocks | Required by the hackathon and project rules. |
25
 
26
  ## Parameter Budget
 
34
  | Vision | MiniCPM-V 2.6 optional path | ~8B | yes, when enabled |
35
  | Text base | Stable baseline mock text | 0 | no model parameters |
36
  | Optional text base | `Qwen/Qwen2.5-1.5B-Instruct` | ~1.5B | yes, when enabled |
37
+ | Published LoRA v2 GGUF | `qqyule/objectverse-diary-qwen15b-lora` / `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf` | ~1.5B base, quantized file | yes, if enabled |
38
  | Published LoRA adapter | `qqyule/objectverse-diary-qwen15b-lora` | small adapter over base model | yes, when enabled |
39
  | Stable baseline total | Mock text + optional wired vision not active by default | 0 active model parameters by default | <= 32B |
40
 
 
63
 
64
  Current preview data is deterministic and mock-generated. It should only be used for schema validation, dry-run validation, and workflow planning until real candidate samples are generated and curated.
65
 
66
+ The Modal training scaffold defaults to `Qwen/Qwen2.5-1.5B-Instruct` and saves adapter artifacts to a Modal Volume. `data/train/objectverse_sft_curated_v2.jsonl` contains 200 synthetic curated rows covering 40 everyday objects and 5 personality modes. It is published at `https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated` as `objectverse_sft_curated_v2.jsonl`.
67
 
68
  Published adapter:
69
 
 
71
  https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
72
  ```
73
 
74
+ Current v2 training run summary:
75
 
76
  - Platform: Modal
77
+ - Run name: `objectverse-diary-qwen15b-lora-v2`
78
  - Base model: `Qwen/Qwen2.5-1.5B-Instruct`
79
+ - Dataset: 200 synthetic curated v2 rows
80
+ - Train / eval rows: 180 / 20
81
+ - Steps: 120
82
+ - Max sequence length: 1536
83
+ - Learning rate: 0.0001
84
+ - Effective batch size: 8
85
+ - LoRA rank / alpha / dropout: 32 / 64 / 0.05
86
+ - Assistant-output-only loss: enabled
87
+ - Train loss: 0.3240
88
+ - Eval loss: 0.0162
89
+ - Epoch: 5.2222
90
+ - GGUF conversion: completed with pinned `llama.cpp` commit `8f83d6c271d194bde2d410145a0ce73bc42e85cd`
91
+ - Published GGUF: `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`
92
 
93
  GGUF smoke status:
94
 
95
+ - Repo: `qqyule/objectverse-diary-qwen15b-lora`
96
+ - File: `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`
97
  - Local helper: `scripts/check_llama_cpp_smoke.py`
98
+ - Local result: passed on 2026-06-08 with `llama-cpp text generation`, no `text-fallback-to-mock`, schema-valid persona and diary, and non-empty chat reply.
99
+ - Space result: not run; do not claim live Space text runtime until a separate Space validation passes.
100
 
101
  ## Safety And Privacy
102
 
docs/SOCIAL_POST.md CHANGED
@@ -5,8 +5,8 @@
5
  I built Objectverse Diary for Build Small Hackathon: a Gradio app where everyday objects wake up, get secret personas, write diaries, chat with you, and generate share cards.
6
 
7
  Stable demo: mock-safe, reproducible, no commercial AI APIs.
8
- MiniCPM-V hosted validation now passes for the vision path; llama.cpp is wired behind a local GGUF smoke helper.
9
- Synthetic curated dataset + Qwen 1.5B LoRA adapter are published as training evidence.
10
 
11
  Space: https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
12
 
@@ -23,9 +23,9 @@ Objectverse Diary is my Build Small Hackathon project: a strange little object a
23
  - a shareable personality card
24
  - an anonymized trace record
25
 
26
- The stable submission baseline is mock-safe and reproducible, with no commercial AI APIs. MiniCPM-V vision is wired and hosted-validated on ZeroGPU, while llama.cpp text remains an optional local GGUF path.
27
 
28
- I also published a small synthetic curated SFT dataset and a Qwen 1.5B LoRA test adapter for Well-Tuned evidence. The adapter is not wired into the public Space runtime yet; the live demo stays intentionally reliable.
29
 
30
  Space:
31
  https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
@@ -38,4 +38,4 @@ https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
38
 
39
  - Add GitHub URL after push is confirmed.
40
  - Add demo video URL after recording.
41
- - Do not claim GGUF smoke test or live LoRA runtime wiring are complete.
 
5
  I built Objectverse Diary for Build Small Hackathon: a Gradio app where everyday objects wake up, get secret personas, write diaries, chat with you, and generate share cards.
6
 
7
  Stable demo: mock-safe, reproducible, no commercial AI APIs.
8
+ MiniCPM-V hosted validation now passes for the vision path; local llama.cpp smoke passes with the published LoRA v2 Q4_K_M GGUF.
9
+ Synthetic curated v2 dataset + Qwen 1.5B LoRA v2 adapter/GGUF are published as model evidence.
10
 
11
  Space: https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
12
 
 
23
  - a shareable personality card
24
  - an anonymized trace record
25
 
26
+ The stable submission baseline is mock-safe and reproducible, with no commercial AI APIs. MiniCPM-V vision is wired and hosted-validated on ZeroGPU, while llama.cpp text is validated locally through an optional GGUF path.
27
 
28
+ I also published a 200-row synthetic curated v2 SFT dataset, a Qwen 1.5B LoRA v2 adapter, and a Q4_K_M GGUF for model evidence. The GGUF is not wired into the public Space runtime yet; the live demo stays intentionally reliable.
29
 
30
  Space:
31
  https://huggingface.co/spaces/build-small-hackathon/ObjectverseDiary
 
38
 
39
  - Add GitHub URL after push is confirmed.
40
  - Add demo video URL after recording.
41
+ - Do not claim live Space LoRA/GGUF runtime wiring is complete.
docs/SUBMISSION_GUIDE.md CHANGED
@@ -24,6 +24,7 @@
24
  - Public mock traces: `data/traces/samples/`
25
  - Stable demo baseline: Gradio example buttons replay committed sample traces first, then fall back to the live generation pipeline if a cached trace is missing.
26
  - Optional llama.cpp runtime wiring: `src/models/llama_cpp_runner.py`
 
27
 
28
  ## Completed Locally
29
 
@@ -33,15 +34,16 @@
33
  - Optional llama.cpp text runtime wiring through `TEXT_MODEL_PATH`.
34
  - Hosted Space VLM validation script, report, JSON summary, and trace evidence export.
35
  - Hosted Space VLM probe support, latest failure-note update support, and passing MiniCPM-V ZeroGPU validation after adding an `HF_TOKEN` Space secret for gated model access.
36
- - Local GGUF smoke-test helper for `Qwen/Qwen2.5-1.5B-Instruct-GGUF` / `qwen2.5-1.5b-instruct-q4_k_m.gguf`; actual GGUF smoke remains pending.
37
- - Synthetic curated SFT dataset published to Hugging Face Datasets.
38
- - Modal Qwen 1.5B LoRA test run completed and adapter published to Hugging Face Models.
 
39
  - Field Notes draft, demo video script, and social post draft for the stable submission package.
40
 
41
  ## Not Completed Yet
42
 
43
- - Real GGUF `TEXT_MODEL_PATH` smoke test and final text model parameter count. The recommended baseline GGUF has been selected, but not downloaded or run.
44
- - Real model traces, GGUF conversion, and app runtime wiring for the published adapter.
45
  - Field Notes publication URL, recorded demo video URL, social post URL, and final public submission.
46
 
47
  ## Final Checks
 
24
  - Public mock traces: `data/traces/samples/`
25
  - Stable demo baseline: Gradio example buttons replay committed sample traces first, then fall back to the live generation pipeline if a cached trace is missing.
26
  - Optional llama.cpp runtime wiring: `src/models/llama_cpp_runner.py`
27
+ - Published LoRA v2 Q4_K_M GGUF: https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora/blob/main/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf
28
 
29
  ## Completed Locally
30
 
 
34
  - Optional llama.cpp text runtime wiring through `TEXT_MODEL_PATH`.
35
  - Hosted Space VLM validation script, report, JSON summary, and trace evidence export.
36
  - Hosted Space VLM probe support, latest failure-note update support, and passing MiniCPM-V ZeroGPU validation after adding an `HF_TOKEN` Space secret for gated model access.
37
+ - Local GGUF smoke-test helper passed with `models/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`; trace text runtime was `llama-cpp text generation` and no `text-fallback-to-mock` was present.
38
+ - Synthetic curated v2 SFT dataset published to Hugging Face Datasets: 200 rows, 40 objects, 5 personality modes.
39
+ - Modal Qwen 1.5B LoRA v2 run completed and adapter published to Hugging Face Models.
40
+ - LoRA v2 adapter merged into `Qwen/Qwen2.5-1.5B-Instruct`, converted with pinned `llama.cpp`, quantized to Q4_K_M, and uploaded to the same model repo.
41
  - Field Notes draft, demo video script, and social post draft for the stable submission package.
42
 
43
  ## Not Completed Yet
44
 
45
+ - Hosted Space text runtime validation with the published GGUF. The local runtime passed, but the public Space has not been switched from mock-safe text.
46
+ - Real text-model traces from the hosted Space.
47
  - Field Notes publication URL, recorded demo video URL, social post URL, and final public submission.
48
 
49
  ## Final Checks
docs/architecture-diagram.html ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Objectverse Diary Architecture Diagram</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: 'JetBrains Mono', monospace;
17
+ background: #020617;
18
+ min-height: 100vh;
19
+ padding: 2rem;
20
+ color: white;
21
+ }
22
+
23
+ .container {
24
+ max-width: 1200px;
25
+ margin: 0 auto;
26
+ }
27
+
28
+ .header {
29
+ margin-bottom: 2rem;
30
+ }
31
+
32
+ .header-row {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 1rem;
36
+ margin-bottom: 0.5rem;
37
+ }
38
+
39
+ .pulse-dot {
40
+ width: 12px;
41
+ height: 12px;
42
+ background: #22d3ee;
43
+ border-radius: 50%;
44
+ animation: pulse 2s infinite;
45
+ }
46
+
47
+ @keyframes pulse {
48
+ 0%, 100% { opacity: 1; transform: scale(1); }
49
+ 50% { opacity: 0.4; transform: scale(0.9); }
50
+ }
51
+
52
+ h1 {
53
+ font-size: 1.5rem;
54
+ font-weight: 700;
55
+ letter-spacing: -0.025em;
56
+ }
57
+
58
+ .subtitle {
59
+ color: #94a3b8;
60
+ font-size: 0.875rem;
61
+ margin-left: 1.75rem;
62
+ }
63
+
64
+ .diagram-container {
65
+ background: rgba(15, 23, 42, 0.5);
66
+ border-radius: 1rem;
67
+ border: 1px solid #1e293b;
68
+ padding: 1.5rem;
69
+ overflow-x: auto;
70
+ }
71
+
72
+ svg {
73
+ width: 100%;
74
+ min-width: 950px;
75
+ display: block;
76
+ }
77
+
78
+ .cards {
79
+ display: grid;
80
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
81
+ gap: 1rem;
82
+ margin-top: 2rem;
83
+ }
84
+
85
+ .card {
86
+ background: rgba(15, 23, 42, 0.5);
87
+ border-radius: 0.75rem;
88
+ border: 1px solid #1e293b;
89
+ padding: 1.25rem;
90
+ }
91
+
92
+ .card-header {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 0.5rem;
96
+ margin-bottom: 0.75rem;
97
+ }
98
+
99
+ .card-dot {
100
+ width: 8px;
101
+ height: 8px;
102
+ border-radius: 50%;
103
+ }
104
+
105
+ .card-dot.cyan { background: #22d3ee; }
106
+ .card-dot.emerald { background: #34d399; }
107
+ .card-dot.violet { background: #a78bfa; }
108
+ .card-dot.amber { background: #fbbf24; }
109
+ .card-dot.rose { background: #fb7185; }
110
+
111
+ .card h3 {
112
+ font-size: 0.875rem;
113
+ font-weight: 600;
114
+ }
115
+
116
+ .card ul {
117
+ list-style: none;
118
+ color: #94a3b8;
119
+ font-size: 0.75rem;
120
+ }
121
+
122
+ .card li {
123
+ margin-bottom: 0.375rem;
124
+ }
125
+
126
+ .footer {
127
+ text-align: center;
128
+ margin-top: 2rem;
129
+ color: #475569;
130
+ font-size: 0.75rem;
131
+ }
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <div class="container">
136
+ <!-- Header -->
137
+ <div class="header">
138
+ <div class="header-row">
139
+ <div class="pulse-dot"></div>
140
+ <h1>Objectverse Diary Architecture</h1>
141
+ </div>
142
+ <p class="subtitle">Multi-layered small model pipeline for the Build Small Hackathon (An Adventure in Thousand Token Wood)</p>
143
+ </div>
144
+
145
+ <!-- Main Diagram -->
146
+ <div class="diagram-container">
147
+ <svg viewBox="0 0 1000 680">
148
+ <!-- Definitions -->
149
+ <defs>
150
+ <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
151
+ <polygon points="0 0, 10 3.5, 0 7" fill="#64748b" />
152
+ </marker>
153
+ <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
154
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.5"/>
155
+ </pattern>
156
+ </defs>
157
+
158
+ <!-- Background Grid -->
159
+ <rect width="100%" height="100%" fill="url(#grid)" />
160
+
161
+ <!-- Region/Cloud Boundary (Hugging Face Space Sandbox) -->
162
+ <rect x="160" y="40" width="700" height="600" rx="12" fill="rgba(251, 191, 36, 0.02)" stroke="#fbbf24" stroke-width="1.2" stroke-dasharray="8,4"/>
163
+ <text x="175" y="62" fill="#fbbf24" font-size="10" font-weight="600">Hugging Face Space Runtime Environment</text>
164
+
165
+ <!-- Dynamic GPU Security Boundary (ZeroGPU Space Context) -->
166
+ <rect x="630" y="90" width="210" height="400" rx="10" fill="rgba(244, 63, 94, 0.02)" stroke="#fb7185" stroke-width="1" stroke-dasharray="4,4"/>
167
+ <text x="640" y="110" fill="#fb7185" font-size="8" font-weight="600">ZeroGPU Allocation Sandbox</text>
168
+
169
+ <!-- Connections (Drawn early so they layer behind components) -->
170
+ <!-- User -> UI Layer -->
171
+ <line x1="120" y1="315" x2="198" y2="315" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
172
+ <text x="159" y="304" fill="#94a3b8" font-size="8" font-weight="600" text-anchor="middle">HTTPS</text>
173
+
174
+ <!-- UI Layer -> Pipeline Coordinator -->
175
+ <line x1="340" y1="315" x2="418" y2="315" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
176
+ <text x="379" y="304" fill="#94a3b8" font-size="8" text-anchor="middle">WS/Post</text>
177
+
178
+ <!-- Coordinator -> Vision Runner -->
179
+ <path d="M 550 280 L 590 280 Q 610 280 610 210 L 638 210" fill="none" stroke="#34d399" stroke-width="1.5" stroke-dasharray="2,2" marker-end="url(#arrowhead)"/>
180
+ <text x="590" y="240" fill="#fb7185" font-size="8" text-anchor="middle">@zero_gpu</text>
181
+
182
+ <!-- Coordinator -> Llama.cpp Runner -->
183
+ <path d="M 550 330 L 590 330 Q 610 330 610 395 L 638 395" fill="none" stroke="#34d399" stroke-width="1.5" marker-end="url(#arrowhead)"/>
184
+ <text x="595" y="360" fill="#34d399" font-size="8" text-anchor="middle">Local GGUF</text>
185
+
186
+ <!-- Coordinator -> Renderer/Traces -->
187
+ <line x1="475" y1="365" x2="475" y2="473" stroke="#34d399" stroke-width="1.5" marker-end="url(#arrowhead)"/>
188
+ <text x="483" y="420" fill="#94a3b8" font-size="8">Local Save</text>
189
+
190
+ <!-- Vision Model -> HF Hub -->
191
+ <line x1="810" y1="210" x2="888" y2="280" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="4,4" marker-end="url(#arrowhead)"/>
192
+ <text x="850" y="235" fill="#94a3b8" font-size="7" text-anchor="middle">gated pull</text>
193
+
194
+ <!-- Text Model -> HF Hub -->
195
+ <line x1="810" y1="395" x2="888" y2="330" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="4,4" marker-end="url(#arrowhead)"/>
196
+ <text x="850" y="370" fill="#94a3b8" font-size="7" text-anchor="middle">GGUF pull</text>
197
+
198
+ <!-- =================================================================
199
+ COMPONENTS (Opaque backgrounds + Semi-transparent borders)
200
+ ================================================================= -->
201
+
202
+ <!-- 1. Users / Browser -->
203
+ <rect x="20" y="270" width="100" height="90" rx="6" fill="#0f172a" />
204
+ <rect x="20" y="270" width="100" height="90" rx="6" fill="rgba(30, 41, 59, 0.5)" stroke="#94a3b8" stroke-width="1.5"/>
205
+ <text x="70" y="295" fill="white" font-size="11" font-weight="700" text-anchor="middle">Users</text>
206
+ <text x="70" y="315" fill="#94a3b8" font-size="8" text-anchor="middle">Upload Image</text>
207
+ <text x="70" y="330" fill="#94a3b8" font-size="8" text-anchor="middle">Chat Session</text>
208
+ <text x="70" y="345" fill="#22d3ee" font-size="8" text-anchor="middle">Web/Mobile</text>
209
+
210
+ <!-- 2. UI Layer (Gradio) -->
211
+ <rect x="200" y="190" width="140" height="250" rx="8" fill="#0f172a" />
212
+ <rect x="200" y="190" width="140" height="250" rx="8" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1.5"/>
213
+ <text x="270" y="215" fill="white" font-size="12" font-weight="700" text-anchor="middle">Gradio Web UI</text>
214
+ <text x="270" y="235" fill="#94a3b8" font-size="8" text-anchor="middle">app.py / src/ui/</text>
215
+ <line x1="210" y1="245" x2="330" y2="245" stroke="#1e293b" stroke-width="1"/>
216
+ <text x="215" y="265" fill="#22d3ee" font-size="8" font-weight="600">• Image Drag-Drop</text>
217
+ <text x="215" y="285" fill="#22d3ee" font-size="8" font-weight="600">• Persona Selector</text>
218
+ <text x="215" y="305" fill="#22d3ee" font-size="8" font-weight="600">• Typewriter Diary</text>
219
+ <text x="215" y="325" fill="#22d3ee" font-size="8" font-weight="600">• Character Chat</text>
220
+ <text x="215" y="345" fill="#22d3ee" font-size="8" font-weight="600">• SVG Card Render</text>
221
+ <line x1="210" y1="365" x2="330" y2="365" stroke="#1e293b" stroke-width="1"/>
222
+ <text x="270" y="385" fill="#94a3b8" font-size="8" text-anchor="middle">Example Gallery Cache</text>
223
+ <text x="270" y="400" fill="#22d3ee" font-size="8" text-anchor="middle">(Deterministic Baseline)</text>
224
+
225
+ <!-- 3. Pipeline Coordinator -->
226
+ <rect x="420" y="240" width="130" height="125" rx="6" fill="#0f172a" />
227
+ <rect x="420" y="240" width="130" height="125" rx="6" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1.5"/>
228
+ <text x="485" y="265" fill="white" font-size="11" font-weight="700" text-anchor="middle">Pipeline Core</text>
229
+ <text x="485" y="280" fill="#94a3b8" font-size="8" text-anchor="middle">src/pipeline.py</text>
230
+ <line x1="430" y1="290" x2="540" y2="290" stroke="#1e293b" stroke-width="1"/>
231
+ <text x="435" y="305" fill="#34d399" font-size="8">• State Routing</text>
232
+ <text x="435" y="320" fill="#34d399" font-size="8">• Fallback Logic</text>
233
+ <text x="435" y="335" fill="#34d399" font-size="8">• Parse Schemas</text>
234
+ <text x="485" y="352" fill="#e2e8f0" font-size="7" text-anchor="middle">Pydantic validation</text>
235
+
236
+ <!-- 4. Vision Runner -->
237
+ <rect x="645" y="150" width="165" height="100" rx="6" fill="#0f172a" />
238
+ <rect x="645" y="150" width="165" height="100" rx="6" fill="rgba(120, 53, 15, 0.3)" stroke="#fbbf24" stroke-width="1.5"/>
239
+ <text x="727" y="175" fill="white" font-size="11" font-weight="700" text-anchor="middle">Vision Backend</text>
240
+ <text x="727" y="190" fill="#94a3b8" font-size="8" text-anchor="middle">src/models/vision_runner</text>
241
+ <line x1="655" y1="200" x2="800" y2="200" stroke="#1e293b" stroke-width="1"/>
242
+ <text x="660" y="215" fill="#fbbf24" font-size="8" font-weight="600">MiniCPM-V 2.6 (8B)</text>
243
+ <text x="660" y="230" fill="#94a3b8" font-size="8">ZeroGPU compatible</text>
244
+ <text x="660" y="242" fill="#fb7185" font-size="7">Fallback to mock on failure</text>
245
+
246
+ <!-- 5. Llama.cpp Runner -->
247
+ <rect x="645" y="335" width="165" height="110" rx="6" fill="#0f172a" />
248
+ <rect x="645" y="335" width="165" height="110" rx="6" fill="rgba(76, 29, 149, 0.4)" stroke="#a78bfa" stroke-width="1.5"/>
249
+ <text x="727" y="360" fill="white" font-size="11" font-weight="700" text-anchor="middle">Text Backend</text>
250
+ <text x="727" y="375" fill="#94a3b8" font-size="8" text-anchor="middle">llama_cpp_runner.py</text>
251
+ <line x1="655" y1="385" x2="800" y2="385" stroke="#1e293b" stroke-width="1"/>
252
+ <text x="660" y="400" fill="#a78bfa" font-size="8" font-weight="600">Qwen 1.5B GGUF</text>
253
+ <text x="660" y="415" fill="#94a3b8" font-size="8">Merged LoRA v2 Adapter</text>
254
+ <text x="660" y="428" fill="#fbbf24" font-size="7">Deterministic fallback runtime</text>
255
+
256
+ <!-- 6. Card Renderer & Traces (Opaque + Slate border) -->
257
+ <rect x="420" y="480" width="130" height="115" rx="6" fill="#0f172a" />
258
+ <rect x="420" y="480" width="130" height="115" rx="6" fill="rgba(30, 41, 59, 0.5)" stroke="#94a3b8" stroke-width="1.5"/>
259
+ <text x="485" y="505" fill="white" font-size="11" font-weight="700" text-anchor="middle">Output Services</text>
260
+ <text x="485" y="520" fill="#94a3b8" font-size="8" text-anchor="middle">renderer/ &amp; traces/</text>
261
+ <line x1="430" y1="530" x2="540" y2="530" stroke="#1e293b" stroke-width="1"/>
262
+ <text x="435" y="545" fill="#94a3b8" font-size="8">• Card HTML Gen</text>
263
+ <text x="435" y="560" fill="#94a3b8" font-size="8">• Anonymizer Traces</text>
264
+ <text x="435" y="575" fill="#94a3b8" font-size="8">• data/traces/*.jsonl</text>
265
+
266
+ <!-- 7. Hugging Face Hub (External Repository) -->
267
+ <rect x="890" y="240" width="90" height="180" rx="8" fill="#0f172a" />
268
+ <rect x="890" y="240" width="90" height="180" rx="8" fill="rgba(30, 41, 59, 0.5)" stroke="#94a3b8" stroke-width="1.5"/>
269
+ <text x="935" y="270" fill="white" font-size="11" font-weight="700" text-anchor="middle">HF Hub</text>
270
+ <text x="935" y="285" fill="#94a3b8" font-size="8" text-anchor="middle">Remote Assets</text>
271
+ <line x1="900" y1="298" x2="970" y2="298" stroke="#1e293b" stroke-width="1"/>
272
+ <text x="905" y="315" fill="#94a3b8" font-size="8">• SFT Dataset</text>
273
+ <text x="905" y="335" fill="#94a3b8" font-size="8">• LoRA Weights</text>
274
+ <text x="905" y="355" fill="#94a3b8" font-size="8">• GGUF Files</text>
275
+ <text x="905" y="375" fill="#fb7185" font-size="8">• Gate models</text>
276
+
277
+ <!-- =================================================================
278
+ DIAGRAM LEGEND
279
+ ================================================================= -->
280
+ <text x="180" y="515" fill="white" font-size="10" font-weight="600">Legend</text>
281
+
282
+ <rect x="180" y="527" width="16" height="10" rx="2" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1"/>
283
+ <text x="202" y="535" fill="#94a3b8" font-size="8">UI Layer (Gradio)</text>
284
+
285
+ <rect x="180" y="543" width="16" height="10" rx="2" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1"/>
286
+ <text x="202" y="551" fill="#94a3b8" font-size="8">Controller Layer (Python)</text>
287
+
288
+ <rect x="180" y="559" width="16" height="10" rx="2" fill="rgba(120, 53, 15, 0.3)" stroke="#fbbf24" stroke-width="1"/>
289
+ <text x="202" y="567" fill="#94a3b8" font-size="8">Vision Engine (MiniCPM-V)</text>
290
+
291
+ <rect x="180" y="575" width="16" height="10" rx="2" fill="rgba(76, 29, 149, 0.4)" stroke="#a78bfa" stroke-width="1"/>
292
+ <text x="202" y="583" fill="#94a3b8" font-size="8">Text Engine (Llama.cpp GGUF)</text>
293
+
294
+ <rect x="180" y="591" width="16" height="10" rx="2" fill="rgba(30, 41, 59, 0.5)" stroke="#94a3b8" stroke-width="1"/>
295
+ <text x="202" y="599" fill="#94a3b8" font-size="8">External &amp; File Outputs</text>
296
+
297
+ <rect x="180" y="607" width="16" height="10" rx="2" fill="transparent" stroke="#fb7185" stroke-width="1" stroke-dasharray="2,2"/>
298
+ <text x="202" y="615" fill="#94a3b8" font-size="8">Security/Dynamic Hardware Group</text>
299
+ </svg>
300
+ </div>
301
+
302
+ <!-- Info Cards -->
303
+ <div class="cards">
304
+ <div class="card">
305
+ <div class="card-header">
306
+ <div class="card-dot cyan"></div>
307
+ <h3>UI &amp; Frontend Layer</h3>
308
+ </div>
309
+ <ul>
310
+ <li>• <b>English-First Copy</b>: Retro archive design, warm paper, mystery vibe</li>
311
+ <li>• <b>Deterministic Fallback</b>: Example gallery reads committed mock records</li>
312
+ <li>• <b>Interactive Sandbox</b>: Full chat session maintaining object persona</li>
313
+ </ul>
314
+ </div>
315
+
316
+ <div class="card">
317
+ <div class="card-header">
318
+ <div class="card-dot emerald"></div>
319
+ <h3>Pipeline Coordinator</h3>
320
+ </div>
321
+ <ul>
322
+ <li>• <b>Modular Routing</b>: Vision descriptions trigger first-person diaries</li>
323
+ <li>• <b>Pydantic Validation</b>: Strict checks on JSON output from LLM</li>
324
+ <li>• <b>Trace Compliance</b>: Generates anonymized session logs to JSONL files</li>
325
+ </ul>
326
+ </div>
327
+
328
+ <div class="card">
329
+ <div class="card-header">
330
+ <div class="card-dot violet"></div>
331
+ <h3>Dual-Engine Model Execution</h3>
332
+ </div>
333
+ <ul>
334
+ <li>• <b>MiniCPM-V 2.6 (8B)</b>: Runs via HF Spaces ZeroGPU dynamically</li>
335
+ <li>• <b>Llama.cpp (1.5B)</b>: Runs highly optimized GGUF adapter locally</li>
336
+ <li>• <b>Flexible Mock Fallback</b>: Ensures 100% runtime uptime for judges</li>
337
+ </ul>
338
+ </div>
339
+ </div>
340
+
341
+ <!-- Footer -->
342
+ <p class="footer">
343
+ Objectverse Diary • Created for Build Small Hackathon • June 2026
344
+ </p>
345
+ </div>
346
+ </body>
347
+ </html>
requirements-training.txt CHANGED
@@ -1,2 +1,3 @@
1
  modal>=1,<2
2
  huggingface_hub>=0.34,<1
 
 
1
  modal>=1,<2
2
  huggingface_hub>=0.34,<1
3
+ peft>=0.19,<1
scripts/README.md CHANGED
@@ -7,12 +7,15 @@ Implemented initial scripts:
7
  - `check_initial_stage.py`: verifies required files, runtime defaults, sample traces, pipeline, and Gradio build.
8
  - `generate_sample_traces.py`: creates six stable public mock traces under `data/traces/samples/`.
9
  - `generate_dataset.py`: creates deterministic SFT preview JSONL for schema and curation planning.
10
- - `prepare_curated_dataset.py`: creates 50 synthetic curated SFT rows for Modal LoRA pipeline testing.
11
  - `export_traces.py`: exports validated public sample traces to JSONL for dataset-style publishing.
12
  - `check_space_vlm.py`: validates MiniCPM-V object understanding on the hosted Hugging Face Space with three temporary public test images.
13
  - `check_llama_cpp_smoke.py`: smoke-tests the optional llama.cpp text runtime with an external GGUF model.
14
- - `finetune_lora.py`: validates SFT JSONL locally and defines the Modal LoRA training scaffold for the future Well-Tuned path.
 
15
  - `publish_hf_adapter.py`: uploads a downloaded LoRA adapter folder to Hugging Face Hub.
 
 
16
 
17
  Expected files during implementation:
18
 
@@ -28,15 +31,60 @@ Modal LoRA dry-run:
28
  --run-name objectverse-diary-qwen15b-curated-test
29
  ```
30
 
31
- Modal LoRA training after explicit confirmation:
32
 
33
  ```bash
34
- modal run scripts/finetune_lora.py \
35
- --dataset data/train/objectverse_sft_curated.jsonl \
36
- --run-name objectverse-diary-qwen15b-curated-test \
37
- --max-steps 20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ```
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  Training dependencies are intentionally separate from the Space runtime:
41
 
42
  ```bash
@@ -56,22 +104,61 @@ or configure `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` through your shell/secret
56
  After a successful Modal run, download the adapter from the output volume into ignored local exports. Modal's directory download behavior can vary; downloading individual adapter files into a directory is the safest path.
57
 
58
  ```bash
59
- mkdir -p exports/objectverse-diary-qwen15b-curated-test-adapter-dir
60
  for file in vocab.json tokenizer_config.json tokenizer.json special_tokens_map.json merges.txt chat_template.jinja added_tokens.json adapter_model.safetensors adapter_config.json README.md; do
61
  modal volume get objectverse-diary-lora-output \
62
- "objectverse-diary-qwen15b-curated-test/adapter/$file" \
63
- "exports/objectverse-diary-qwen15b-curated-test-adapter-dir/$file"
64
  done
 
 
 
 
 
 
65
  ```
66
 
67
  Then upload the adapter to Hugging Face Hub:
68
 
69
  ```bash
70
  .venv/bin/python -B scripts/publish_hf_adapter.py \
71
- --adapter-dir exports/objectverse-diary-qwen15b-curated-test-adapter-dir \
72
- --repo-id qqyule/objectverse-diary-qwen15b-lora
 
73
  ```
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  Space VLM validation:
76
 
77
  ```bash
@@ -88,13 +175,13 @@ External Space changes are explicit:
88
  .venv/bin/python -B scripts/check_space_vlm.py --configure-space --rollback-to-mock
89
  ```
90
 
91
- Local GGUF smoke test after explicit confirmation:
92
 
93
  ```bash
94
  .venv/bin/python -B scripts/check_llama_cpp_smoke.py \
95
- --model-path models/qwen2.5-1.5b-instruct-q4_k_m.gguf
96
  ```
97
 
98
- Recommended GGUF source: `Qwen/Qwen2.5-1.5B-Instruct-GGUF`, file `qwen2.5-1.5b-instruct-q4_k_m.gguf`. Do not commit the downloaded file.
99
 
100
- Current status: mock trace generation, trace JSONL export, SFT preview generation, synthetic curated dataset publishing, optional MiniCPM-V wiring, optional llama.cpp wiring, hosted Space VLM validation tooling with non-secret probe support, local GGUF smoke helper, Modal LoRA training scaffolding, one Modal LoRA test run, and HF adapter publishing are implemented. Real model validation on Space, actual GGUF smoke, GGUF conversion, and app runtime wiring for the adapter are not completed yet.
 
7
  - `check_initial_stage.py`: verifies required files, runtime defaults, sample traces, pipeline, and Gradio build.
8
  - `generate_sample_traces.py`: creates six stable public mock traces under `data/traces/samples/`.
9
  - `generate_dataset.py`: creates deterministic SFT preview JSONL for schema and curation planning.
10
+ - `prepare_curated_dataset.py`: creates deterministic synthetic curated SFT rows; v1 defaults to 50 rows, v2 defaults to 200 rows across 40 objects and 5 modes.
11
  - `export_traces.py`: exports validated public sample traces to JSONL for dataset-style publishing.
12
  - `check_space_vlm.py`: validates MiniCPM-V object understanding on the hosted Hugging Face Space with three temporary public test images.
13
  - `check_llama_cpp_smoke.py`: smoke-tests the optional llama.cpp text runtime with an external GGUF model.
14
+ - `finetune_lora.py`: validates SFT JSONL locally and defines the Modal LoRA training scaffold with optional eval split, assistant-output-only loss, and tunable LoRA/batch settings.
15
+ - `publish_hf_dataset.py`: validates and uploads curated JSONL files to a Hugging Face Dataset repository.
16
  - `publish_hf_adapter.py`: uploads a downloaded LoRA adapter folder to Hugging Face Hub.
17
+ - `merge_lora_adapter.py`: merges a local PEFT LoRA adapter into a Hugging Face base model and saves tokenizer files.
18
+ - `publish_hf_gguf.py`: validates and uploads a local GGUF file to a Hugging Face model repository.
19
 
20
  Expected files during implementation:
21
 
 
31
  --run-name objectverse-diary-qwen15b-curated-test
32
  ```
33
 
34
+ Modal LoRA v2 dry-run for a larger curated dataset:
35
 
36
  ```bash
37
+ .venv/bin/python -B scripts/prepare_curated_dataset.py \
38
+ --version v2 \
39
+ --count 200 \
40
+ --output data/train/objectverse_sft_curated_v2.jsonl
41
+ ```
42
+
43
+ Publish curated v2 dataset:
44
+
45
+ ```bash
46
+ .venv/bin/python -B scripts/publish_hf_dataset.py \
47
+ --dataset-file data/train/objectverse_sft_curated_v2.jsonl \
48
+ --repo-id qqyule/objectverse-diary-sft-curated \
49
+ --path-in-repo objectverse_sft_curated_v2.jsonl
50
+ ```
51
+
52
+ ```bash
53
+ .venv/bin/python -B scripts/finetune_lora.py \
54
+ --dry-run \
55
+ --dataset data/train/objectverse_sft_curated_v2.jsonl \
56
+ --run-name objectverse-diary-qwen15b-lora-v2 \
57
+ --max-steps 120 \
58
+ --learning-rate 1e-4 \
59
+ --max-seq-length 1536 \
60
+ --lora-r 32 \
61
+ --lora-alpha 64 \
62
+ --per-device-train-batch-size 2 \
63
+ --gradient-accumulation-steps 4 \
64
+ --eval-ratio 0.1 \
65
+ --eval-steps 20
66
  ```
67
 
68
+ Modal LoRA v2 training:
69
+
70
+ ```bash
71
+ modal run --timestamps -n objectverse-diary-qwen15b-lora-v2 scripts/finetune_lora.py \
72
+ --dataset data/train/objectverse_sft_curated_v2.jsonl \
73
+ --run-name objectverse-diary-qwen15b-lora-v2 \
74
+ --max-steps 120 \
75
+ --learning-rate 1e-4 \
76
+ --max-seq-length 1536 \
77
+ --lora-r 32 \
78
+ --lora-alpha 64 \
79
+ --per-device-train-batch-size 2 \
80
+ --gradient-accumulation-steps 4 \
81
+ --eval-ratio 0.1 \
82
+ --eval-steps 20
83
+ ```
84
+
85
+ For epoch-based experiments, set `--max-steps 0` and provide `--num-train-epochs`.
86
+ Assistant-output-only loss is enabled by default; pass `--no-assistant-only-loss` only for debugging full-text loss behavior.
87
+
88
  Training dependencies are intentionally separate from the Space runtime:
89
 
90
  ```bash
 
104
  After a successful Modal run, download the adapter from the output volume into ignored local exports. Modal's directory download behavior can vary; downloading individual adapter files into a directory is the safest path.
105
 
106
  ```bash
107
+ mkdir -p exports/objectverse-diary-qwen15b-lora-v2-adapter-dir
108
  for file in vocab.json tokenizer_config.json tokenizer.json special_tokens_map.json merges.txt chat_template.jinja added_tokens.json adapter_model.safetensors adapter_config.json README.md; do
109
  modal volume get objectverse-diary-lora-output \
110
+ "objectverse-diary-qwen15b-lora-v2/adapter/$file" \
111
+ "exports/objectverse-diary-qwen15b-lora-v2-adapter-dir/$file"
112
  done
113
+ modal volume get objectverse-diary-lora-output \
114
+ objectverse-diary-qwen15b-lora-v2/metrics.json \
115
+ exports/objectverse-diary-qwen15b-lora-v2-adapter-dir/training_metrics.json
116
+ modal volume get objectverse-diary-lora-output \
117
+ objectverse-diary-qwen15b-lora-v2/training_config.json \
118
+ exports/objectverse-diary-qwen15b-lora-v2-adapter-dir/training_config.json
119
  ```
120
 
121
  Then upload the adapter to Hugging Face Hub:
122
 
123
  ```bash
124
  .venv/bin/python -B scripts/publish_hf_adapter.py \
125
+ --adapter-dir exports/objectverse-diary-qwen15b-lora-v2-adapter-dir \
126
+ --repo-id qqyule/objectverse-diary-qwen15b-lora \
127
+ --commit-message "Upload Objectverse Diary Qwen 1.5B LoRA v2"
128
  ```
129
 
130
+ LoRA v2 GGUF conversion and upload:
131
+
132
+ ```bash
133
+ .venv/bin/python -B scripts/merge_lora_adapter.py \
134
+ --base-model Qwen/Qwen2.5-1.5B-Instruct \
135
+ --adapter exports/objectverse-diary-qwen15b-lora-v2-adapter-dir \
136
+ --output exports/objectverse-diary-qwen15b-lora-v2-merged-hf
137
+
138
+ git clone https://github.com/ggml-org/llama.cpp.git .tmp/llama.cpp
139
+ git -C .tmp/llama.cpp checkout 8f83d6c271d194bde2d410145a0ce73bc42e85cd
140
+ cmake -S .tmp/llama.cpp -B .tmp/llama.cpp/build -DCMAKE_BUILD_TYPE=Release
141
+ cmake --build .tmp/llama.cpp/build --target llama-quantize -j
142
+
143
+ .venv/bin/python .tmp/llama.cpp/convert_hf_to_gguf.py \
144
+ exports/objectverse-diary-qwen15b-lora-v2-merged-hf \
145
+ --outfile models/objectverse-diary-qwen15b-lora-v2-f16.gguf \
146
+ --outtype f16
147
+
148
+ .tmp/llama.cpp/build/bin/llama-quantize \
149
+ models/objectverse-diary-qwen15b-lora-v2-f16.gguf \
150
+ models/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf \
151
+ Q4_K_M
152
+
153
+ .venv/bin/python -B scripts/publish_hf_gguf.py \
154
+ --gguf-file models/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf \
155
+ --repo-id qqyule/objectverse-diary-qwen15b-lora \
156
+ --path-in-repo objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf \
157
+ --commit-message "Upload Objectverse Diary Qwen 1.5B LoRA v2 Q4_K_M GGUF"
158
+ ```
159
+
160
+ The final Q4_K_M GGUF is ignored under `models/`. After upload, remove only generated intermediates such as the merged HF folder and F16 GGUF.
161
+
162
  Space VLM validation:
163
 
164
  ```bash
 
175
  .venv/bin/python -B scripts/check_space_vlm.py --configure-space --rollback-to-mock
176
  ```
177
 
178
+ Local LoRA v2 GGUF smoke test:
179
 
180
  ```bash
181
  .venv/bin/python -B scripts/check_llama_cpp_smoke.py \
182
+ --model-path models/objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf
183
  ```
184
 
185
+ Published GGUF source: `qqyule/objectverse-diary-qwen15b-lora`, file `objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf`. Do not commit the downloaded file.
186
 
187
+ Current status: mock trace generation, trace JSONL export, SFT preview generation, synthetic curated v2 dataset publishing, optional MiniCPM-V wiring, optional llama.cpp wiring, hosted Space VLM validation tooling with non-secret probe support, local GGUF smoke helper, Modal LoRA training scaffolding, Modal LoRA v2 training, HF adapter publishing, GGUF conversion, GGUF upload, and local llama.cpp smoke are implemented. Real text-model validation on Space is not completed yet.
scripts/finetune_lora.py CHANGED
@@ -3,7 +3,9 @@
3
  from __future__ import annotations
4
 
5
  import argparse
 
6
  import json
 
7
  import sys
8
  from collections.abc import Callable, Mapping, Sequence
9
  from dataclasses import asdict, dataclass, field
@@ -46,13 +48,48 @@ class TrainingConfig:
46
  run_name: str = DEFAULT_RUN_NAME
47
  base_model: str = DEFAULT_BASE_MODEL
48
  max_steps: int = 80
 
49
  learning_rate: float = 2e-4
50
  max_seq_length: int = 1024
 
 
 
 
 
 
 
 
 
 
51
  lora_r: int = 16
52
  lora_alpha: int = 32
53
  lora_dropout: float = 0.05
54
  target_modules: tuple[str, ...] = field(default_factory=lambda: LORA_TARGET_MODULES)
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def as_remote_dict(self) -> dict[str, object]:
57
  payload = asdict(self)
58
  payload["target_modules"] = list(self.target_modules)
@@ -86,11 +123,21 @@ def record_to_training_text(record: Mapping[str, object]) -> str:
86
  """Convert one validated chat record into a simple fallback training string."""
87
 
88
  messages = _validate_messages(record.get("messages"), line_number=None)
 
 
 
 
 
 
 
 
89
  blocks = []
90
  for message in messages:
91
  role = str(message["role"]).strip().lower()
92
  content = str(message["content"]).strip()
93
  blocks.append(f"{role}:\n{content}")
 
 
94
  return "\n\n".join(blocks).strip()
95
 
96
 
@@ -145,15 +192,29 @@ def _dry_run_summary(
145
  config: TrainingConfig,
146
  ) -> dict[str, object]:
147
  first_text = record_to_training_text(records[0])
 
148
  return {
149
  "mode": "dry-run",
150
  "dataset": str(dataset),
151
  "record_count": len(records),
 
 
152
  "base_model": config.base_model,
153
  "run_name": config.run_name,
154
  "max_steps": config.max_steps,
 
155
  "learning_rate": config.learning_rate,
156
  "max_seq_length": config.max_seq_length,
 
 
 
 
 
 
 
 
 
 
157
  "lora": {
158
  "r": config.lora_r,
159
  "alpha": config.lora_alpha,
@@ -184,7 +245,6 @@ def _train_lora_impl(
184
  from transformers import (
185
  AutoModelForCausalLM,
186
  AutoTokenizer,
187
- DataCollatorForLanguageModeling,
188
  Trainer,
189
  TrainingArguments,
190
  )
@@ -218,49 +278,46 @@ def _train_lora_impl(
218
  model.print_trainable_parameters()
219
 
220
  dataset = Dataset.from_list(
221
- [{"text": _format_training_text(record, tokenizer)} for record in records]
 
 
 
 
 
 
 
 
222
  )
 
223
 
224
- def tokenize_batch(batch: Mapping[str, list[str]]) -> dict[str, object]:
225
- return tokenizer(
226
- batch["text"],
227
- truncation=True,
228
- max_length=config.max_seq_length,
229
- padding=False,
230
- )
231
-
232
- tokenized = dataset.map(
233
- tokenize_batch,
234
- batched=True,
235
- remove_columns=["text"],
236
- desc="Tokenize Objectverse Diary SFT records",
237
  )
 
238
 
239
- training_args = TrainingArguments(
240
- output_dir=str(output_path / "trainer"),
241
- max_steps=config.max_steps,
242
- per_device_train_batch_size=1,
243
- gradient_accumulation_steps=4,
244
- learning_rate=config.learning_rate,
245
- logging_steps=5,
246
- save_strategy="no",
247
- fp16=torch.cuda.is_available(),
248
- report_to=[],
249
- optim="adamw_torch",
250
- )
251
  trainer = Trainer(
252
  model=model,
253
  args=training_args,
254
- train_dataset=tokenized,
255
- data_collator=DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False),
 
256
  )
257
  train_result = trainer.train()
 
 
 
258
 
259
  model.save_pretrained(adapter_path)
260
  tokenizer.save_pretrained(adapter_path)
261
 
262
  metrics = dict(train_result.metrics)
263
- metrics["train_records"] = len(records)
 
 
264
  metrics["base_model"] = config.base_model
265
  (output_path / "metrics.json").write_text(
266
  json.dumps(metrics, indent=2, sort_keys=True),
@@ -278,11 +335,157 @@ def _train_lora_impl(
278
  "mode": "modal-training",
279
  "run_name": config.run_name,
280
  "record_count": len(records),
 
 
281
  "adapter_path": str(adapter_path),
282
  "metrics_path": str(output_path / "metrics.json"),
283
  }
284
 
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  def _training_config_from_payload(payload: Mapping[str, object]) -> TrainingConfig:
287
  target_modules = payload.get("target_modules", LORA_TARGET_MODULES)
288
  if not isinstance(target_modules, Sequence) or isinstance(target_modules, (str, bytes)):
@@ -291,8 +494,19 @@ def _training_config_from_payload(payload: Mapping[str, object]) -> TrainingConf
291
  run_name=str(payload.get("run_name", DEFAULT_RUN_NAME)),
292
  base_model=str(payload.get("base_model", DEFAULT_BASE_MODEL)),
293
  max_steps=int(payload.get("max_steps", 80)),
 
294
  learning_rate=float(payload.get("learning_rate", 2e-4)),
295
  max_seq_length=int(payload.get("max_seq_length", 1024)),
 
 
 
 
 
 
 
 
 
 
296
  lora_r=int(payload.get("lora_r", 16)),
297
  lora_alpha=int(payload.get("lora_alpha", 32)),
298
  lora_dropout=float(payload.get("lora_dropout", 0.05)),
@@ -302,16 +516,39 @@ def _training_config_from_payload(payload: Mapping[str, object]) -> TrainingConf
302
 
303
  def _format_training_text(record: Mapping[str, object], tokenizer: Any) -> str:
304
  messages = _validate_messages(record.get("messages"), line_number=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  if hasattr(tokenizer, "apply_chat_template"):
306
  try:
307
  return tokenizer.apply_chat_template(
308
  messages,
309
  tokenize=False,
310
- add_generation_prompt=False,
311
  )
312
  except Exception:
313
  pass
314
- return record_to_training_text(record)
 
 
 
315
 
316
 
317
  def _print_json(payload: Mapping[str, object]) -> None:
@@ -323,8 +560,22 @@ def _build_config_from_args(args: argparse.Namespace) -> TrainingConfig:
323
  run_name=args.run_name,
324
  base_model=args.base_model,
325
  max_steps=args.max_steps,
 
326
  learning_rate=args.learning_rate,
327
  max_seq_length=args.max_seq_length,
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  )
329
 
330
 
@@ -334,8 +585,22 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
334
  parser.add_argument("--run-name", default=DEFAULT_RUN_NAME)
335
  parser.add_argument("--base-model", default=DEFAULT_BASE_MODEL)
336
  parser.add_argument("--max-steps", type=int, default=80)
 
337
  parser.add_argument("--learning-rate", type=float, default=2e-4)
338
  parser.add_argument("--max-seq-length", type=int, default=1024)
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  parser.add_argument("--dry-run", action="store_true")
340
  return parser.parse_args(argv)
341
 
@@ -390,8 +655,22 @@ if modal is not None:
390
  run_name: str = DEFAULT_RUN_NAME,
391
  base_model: str = DEFAULT_BASE_MODEL,
392
  max_steps: int = 80,
 
393
  learning_rate: float = 2e-4,
394
  max_seq_length: int = 1024,
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  dry_run: bool = False,
396
  ) -> None:
397
  result = run_training_entrypoint(
@@ -400,8 +679,22 @@ if modal is not None:
400
  run_name=run_name,
401
  base_model=base_model,
402
  max_steps=max_steps,
 
403
  learning_rate=learning_rate,
404
  max_seq_length=max_seq_length,
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  ),
406
  dry_run=dry_run,
407
  allow_remote=True,
 
3
  from __future__ import annotations
4
 
5
  import argparse
6
+ import inspect
7
  import json
8
+ import math
9
  import sys
10
  from collections.abc import Callable, Mapping, Sequence
11
  from dataclasses import asdict, dataclass, field
 
48
  run_name: str = DEFAULT_RUN_NAME
49
  base_model: str = DEFAULT_BASE_MODEL
50
  max_steps: int = 80
51
+ num_train_epochs: float = 3.0
52
  learning_rate: float = 2e-4
53
  max_seq_length: int = 1024
54
+ per_device_train_batch_size: int = 1
55
+ gradient_accumulation_steps: int = 4
56
+ eval_ratio: float = 0.1
57
+ eval_steps: int = 10
58
+ warmup_ratio: float = 0.03
59
+ weight_decay: float = 0.0
60
+ logging_steps: int = 5
61
+ save_total_limit: int = 2
62
+ seed: int = 42
63
+ assistant_only_loss: bool = True
64
  lora_r: int = 16
65
  lora_alpha: int = 32
66
  lora_dropout: float = 0.05
67
  target_modules: tuple[str, ...] = field(default_factory=lambda: LORA_TARGET_MODULES)
68
 
69
+ def __post_init__(self) -> None:
70
+ if self.max_steps < 0:
71
+ raise ValueError("max_steps must be 0 or greater.")
72
+ if self.max_steps == 0 and self.num_train_epochs <= 0:
73
+ raise ValueError("num_train_epochs must be greater than 0 when max_steps is 0.")
74
+ if self.per_device_train_batch_size < 1:
75
+ raise ValueError("per_device_train_batch_size must be at least 1.")
76
+ if self.gradient_accumulation_steps < 1:
77
+ raise ValueError("gradient_accumulation_steps must be at least 1.")
78
+ if not 0 <= self.eval_ratio < 1:
79
+ raise ValueError("eval_ratio must be between 0 and 1.")
80
+ if self.eval_steps < 1:
81
+ raise ValueError("eval_steps must be at least 1.")
82
+ if self.logging_steps < 1:
83
+ raise ValueError("logging_steps must be at least 1.")
84
+ if self.save_total_limit < 1:
85
+ raise ValueError("save_total_limit must be at least 1.")
86
+ if self.lora_r < 1:
87
+ raise ValueError("lora_r must be at least 1.")
88
+ if self.lora_alpha < 1:
89
+ raise ValueError("lora_alpha must be at least 1.")
90
+ if not 0 <= self.lora_dropout < 1:
91
+ raise ValueError("lora_dropout must be between 0 and 1.")
92
+
93
  def as_remote_dict(self) -> dict[str, object]:
94
  payload = asdict(self)
95
  payload["target_modules"] = list(self.target_modules)
 
123
  """Convert one validated chat record into a simple fallback training string."""
124
 
125
  messages = _validate_messages(record.get("messages"), line_number=None)
126
+ return _messages_to_training_text(messages)
127
+
128
+
129
+ def _messages_to_training_text(
130
+ messages: Sequence[Mapping[str, str]],
131
+ *,
132
+ add_generation_prompt: bool = False,
133
+ ) -> str:
134
  blocks = []
135
  for message in messages:
136
  role = str(message["role"]).strip().lower()
137
  content = str(message["content"]).strip()
138
  blocks.append(f"{role}:\n{content}")
139
+ if add_generation_prompt:
140
+ blocks.append("assistant:\n")
141
  return "\n\n".join(blocks).strip()
142
 
143
 
 
192
  config: TrainingConfig,
193
  ) -> dict[str, object]:
194
  first_text = record_to_training_text(records[0])
195
+ eval_count = _eval_record_count(len(records), config.eval_ratio)
196
  return {
197
  "mode": "dry-run",
198
  "dataset": str(dataset),
199
  "record_count": len(records),
200
+ "train_record_count": len(records) - eval_count,
201
+ "eval_record_count": eval_count,
202
  "base_model": config.base_model,
203
  "run_name": config.run_name,
204
  "max_steps": config.max_steps,
205
+ "num_train_epochs": config.num_train_epochs,
206
  "learning_rate": config.learning_rate,
207
  "max_seq_length": config.max_seq_length,
208
+ "per_device_train_batch_size": config.per_device_train_batch_size,
209
+ "gradient_accumulation_steps": config.gradient_accumulation_steps,
210
+ "effective_batch_size": (
211
+ config.per_device_train_batch_size * config.gradient_accumulation_steps
212
+ ),
213
+ "eval_ratio": config.eval_ratio,
214
+ "eval_steps": config.eval_steps,
215
+ "warmup_ratio": config.warmup_ratio,
216
+ "weight_decay": config.weight_decay,
217
+ "assistant_only_loss": config.assistant_only_loss,
218
  "lora": {
219
  "r": config.lora_r,
220
  "alpha": config.lora_alpha,
 
245
  from transformers import (
246
  AutoModelForCausalLM,
247
  AutoTokenizer,
 
248
  Trainer,
249
  TrainingArguments,
250
  )
 
278
  model.print_trainable_parameters()
279
 
280
  dataset = Dataset.from_list(
281
+ [
282
+ _tokenize_training_example(
283
+ record,
284
+ tokenizer,
285
+ max_length=config.max_seq_length,
286
+ assistant_only_loss=config.assistant_only_loss,
287
+ )
288
+ for record in records
289
+ ]
290
  )
291
+ train_dataset, eval_dataset = _split_dataset(dataset, config)
292
 
293
+ training_kwargs = _training_arguments_kwargs(
294
+ output_dir=output_path / "trainer",
295
+ config=config,
296
+ has_eval=eval_dataset is not None,
297
+ training_arguments_cls=TrainingArguments,
 
 
 
 
 
 
 
 
298
  )
299
+ training_kwargs["fp16"] = torch.cuda.is_available()
300
 
301
+ training_args = TrainingArguments(**training_kwargs)
 
 
 
 
 
 
 
 
 
 
 
302
  trainer = Trainer(
303
  model=model,
304
  args=training_args,
305
+ train_dataset=train_dataset,
306
+ eval_dataset=eval_dataset,
307
+ data_collator=_build_supervised_data_collator(tokenizer, torch),
308
  )
309
  train_result = trainer.train()
310
+ eval_metrics: dict[str, object] = {}
311
+ if eval_dataset is not None:
312
+ eval_metrics = dict(trainer.evaluate())
313
 
314
  model.save_pretrained(adapter_path)
315
  tokenizer.save_pretrained(adapter_path)
316
 
317
  metrics = dict(train_result.metrics)
318
+ metrics.update(eval_metrics)
319
+ metrics["train_records"] = len(train_dataset)
320
+ metrics["eval_records"] = len(eval_dataset) if eval_dataset is not None else 0
321
  metrics["base_model"] = config.base_model
322
  (output_path / "metrics.json").write_text(
323
  json.dumps(metrics, indent=2, sort_keys=True),
 
335
  "mode": "modal-training",
336
  "run_name": config.run_name,
337
  "record_count": len(records),
338
+ "train_record_count": len(train_dataset),
339
+ "eval_record_count": len(eval_dataset) if eval_dataset is not None else 0,
340
  "adapter_path": str(adapter_path),
341
  "metrics_path": str(output_path / "metrics.json"),
342
  }
343
 
344
 
345
+ def _tokenize_training_example(
346
+ record: Mapping[str, object],
347
+ tokenizer: Any,
348
+ *,
349
+ max_length: int,
350
+ assistant_only_loss: bool,
351
+ ) -> dict[str, list[int]]:
352
+ full_text = _format_training_text(record, tokenizer)
353
+ encoded = tokenizer(
354
+ full_text,
355
+ truncation=True,
356
+ max_length=max_length,
357
+ padding=False,
358
+ add_special_tokens=False,
359
+ )
360
+ input_ids = list(encoded["input_ids"])
361
+ labels = list(input_ids)
362
+
363
+ if assistant_only_loss:
364
+ prompt_text = _format_prompt_text(record, tokenizer)
365
+ prompt_encoded = tokenizer(
366
+ prompt_text,
367
+ truncation=True,
368
+ max_length=max_length,
369
+ padding=False,
370
+ add_special_tokens=False,
371
+ )
372
+ mask_count = min(len(prompt_encoded["input_ids"]), len(labels))
373
+ labels[:mask_count] = [-100] * mask_count
374
+ if not any(label != -100 for label in labels):
375
+ raise ValueError(
376
+ "max_seq_length truncates all assistant labels; increase max_seq_length."
377
+ )
378
+
379
+ return {
380
+ "input_ids": input_ids,
381
+ "attention_mask": list(encoded["attention_mask"]),
382
+ "labels": labels,
383
+ }
384
+
385
+
386
+ def _split_dataset(dataset: Any, config: TrainingConfig) -> tuple[Any, Any | None]:
387
+ eval_count = _eval_record_count(len(dataset), config.eval_ratio)
388
+ if eval_count == 0:
389
+ return dataset, None
390
+ split = dataset.train_test_split(test_size=eval_count, shuffle=True, seed=config.seed)
391
+ return split["train"], split["test"]
392
+
393
+
394
+ def _eval_record_count(record_count: int, eval_ratio: float) -> int:
395
+ if record_count < 2 or eval_ratio <= 0:
396
+ return 0
397
+ return max(1, min(record_count - 1, math.ceil(record_count * eval_ratio)))
398
+
399
+
400
+ def _training_arguments_kwargs(
401
+ *,
402
+ output_dir: Path,
403
+ config: TrainingConfig,
404
+ has_eval: bool,
405
+ training_arguments_cls: Any | None = None,
406
+ ) -> dict[str, object]:
407
+ kwargs: dict[str, object] = {
408
+ "output_dir": str(output_dir),
409
+ "per_device_train_batch_size": config.per_device_train_batch_size,
410
+ "gradient_accumulation_steps": config.gradient_accumulation_steps,
411
+ "learning_rate": config.learning_rate,
412
+ "logging_steps": config.logging_steps,
413
+ "warmup_ratio": config.warmup_ratio,
414
+ "weight_decay": config.weight_decay,
415
+ "report_to": [],
416
+ "optim": "adamw_torch",
417
+ "seed": config.seed,
418
+ "data_seed": config.seed,
419
+ }
420
+ if config.max_steps > 0:
421
+ kwargs["max_steps"] = config.max_steps
422
+ else:
423
+ kwargs["num_train_epochs"] = config.num_train_epochs
424
+
425
+ if has_eval:
426
+ kwargs.update(
427
+ {
428
+ "eval_steps": config.eval_steps,
429
+ "save_steps": config.eval_steps,
430
+ "save_strategy": "steps",
431
+ "save_total_limit": config.save_total_limit,
432
+ "load_best_model_at_end": True,
433
+ "metric_for_best_model": "eval_loss",
434
+ "greater_is_better": False,
435
+ }
436
+ )
437
+ if training_arguments_cls is None:
438
+ kwargs["eval_strategy"] = "steps"
439
+ else:
440
+ _set_eval_strategy_kwarg(kwargs, training_arguments_cls, "steps")
441
+ else:
442
+ kwargs["save_strategy"] = "no"
443
+
444
+ return kwargs
445
+
446
+
447
+ def _set_eval_strategy_kwarg(
448
+ kwargs: dict[str, object],
449
+ training_arguments_cls: Any,
450
+ strategy: str,
451
+ ) -> None:
452
+ parameters = inspect.signature(training_arguments_cls.__init__).parameters
453
+ if "eval_strategy" in parameters:
454
+ kwargs["eval_strategy"] = strategy
455
+ elif "evaluation_strategy" in parameters:
456
+ kwargs["evaluation_strategy"] = strategy
457
+ else:
458
+ kwargs["do_eval"] = strategy != "no"
459
+
460
+
461
+ def _build_supervised_data_collator(tokenizer: Any, torch_module: Any) -> Callable:
462
+ def collate(features: list[Mapping[str, list[int]]]) -> dict[str, object]:
463
+ labels = [list(feature["labels"]) for feature in features]
464
+ model_features = [
465
+ {
466
+ "input_ids": list(feature["input_ids"]),
467
+ "attention_mask": list(feature["attention_mask"]),
468
+ }
469
+ for feature in features
470
+ ]
471
+ batch = tokenizer.pad(model_features, padding=True, return_tensors="pt")
472
+ max_length = batch["input_ids"].shape[1]
473
+ label_tensor = torch_module.full(
474
+ (len(labels), max_length),
475
+ -100,
476
+ dtype=torch_module.long,
477
+ )
478
+ for index, label in enumerate(labels):
479
+ label_tensor[index, : len(label)] = torch_module.tensor(
480
+ label,
481
+ dtype=torch_module.long,
482
+ )
483
+ batch["labels"] = label_tensor
484
+ return batch
485
+
486
+ return collate
487
+
488
+
489
  def _training_config_from_payload(payload: Mapping[str, object]) -> TrainingConfig:
490
  target_modules = payload.get("target_modules", LORA_TARGET_MODULES)
491
  if not isinstance(target_modules, Sequence) or isinstance(target_modules, (str, bytes)):
 
494
  run_name=str(payload.get("run_name", DEFAULT_RUN_NAME)),
495
  base_model=str(payload.get("base_model", DEFAULT_BASE_MODEL)),
496
  max_steps=int(payload.get("max_steps", 80)),
497
+ num_train_epochs=float(payload.get("num_train_epochs", 3.0)),
498
  learning_rate=float(payload.get("learning_rate", 2e-4)),
499
  max_seq_length=int(payload.get("max_seq_length", 1024)),
500
+ per_device_train_batch_size=int(payload.get("per_device_train_batch_size", 1)),
501
+ gradient_accumulation_steps=int(payload.get("gradient_accumulation_steps", 4)),
502
+ eval_ratio=float(payload.get("eval_ratio", 0.1)),
503
+ eval_steps=int(payload.get("eval_steps", 10)),
504
+ warmup_ratio=float(payload.get("warmup_ratio", 0.03)),
505
+ weight_decay=float(payload.get("weight_decay", 0.0)),
506
+ logging_steps=int(payload.get("logging_steps", 5)),
507
+ save_total_limit=int(payload.get("save_total_limit", 2)),
508
+ seed=int(payload.get("seed", 42)),
509
+ assistant_only_loss=bool(payload.get("assistant_only_loss", True)),
510
  lora_r=int(payload.get("lora_r", 16)),
511
  lora_alpha=int(payload.get("lora_alpha", 32)),
512
  lora_dropout=float(payload.get("lora_dropout", 0.05)),
 
516
 
517
  def _format_training_text(record: Mapping[str, object], tokenizer: Any) -> str:
518
  messages = _validate_messages(record.get("messages"), line_number=None)
519
+ return _format_messages(messages, tokenizer, add_generation_prompt=False)
520
+
521
+
522
+ def _format_prompt_text(record: Mapping[str, object], tokenizer: Any) -> str:
523
+ messages = _validate_messages(record.get("messages"), line_number=None)
524
+ assistant_indices = [
525
+ index for index, message in enumerate(messages) if message["role"].lower() == "assistant"
526
+ ]
527
+ if not assistant_indices:
528
+ raise ValueError("assistant_only_loss requires at least one assistant message.")
529
+ prompt_messages = messages[: assistant_indices[-1]]
530
+ return _format_messages(prompt_messages, tokenizer, add_generation_prompt=True)
531
+
532
+
533
+ def _format_messages(
534
+ messages: Sequence[Mapping[str, str]],
535
+ tokenizer: Any,
536
+ *,
537
+ add_generation_prompt: bool,
538
+ ) -> str:
539
  if hasattr(tokenizer, "apply_chat_template"):
540
  try:
541
  return tokenizer.apply_chat_template(
542
  messages,
543
  tokenize=False,
544
+ add_generation_prompt=add_generation_prompt,
545
  )
546
  except Exception:
547
  pass
548
+ return _messages_to_training_text(
549
+ messages,
550
+ add_generation_prompt=add_generation_prompt,
551
+ )
552
 
553
 
554
  def _print_json(payload: Mapping[str, object]) -> None:
 
560
  run_name=args.run_name,
561
  base_model=args.base_model,
562
  max_steps=args.max_steps,
563
+ num_train_epochs=args.num_train_epochs,
564
  learning_rate=args.learning_rate,
565
  max_seq_length=args.max_seq_length,
566
+ per_device_train_batch_size=args.per_device_train_batch_size,
567
+ gradient_accumulation_steps=args.gradient_accumulation_steps,
568
+ eval_ratio=args.eval_ratio,
569
+ eval_steps=args.eval_steps,
570
+ warmup_ratio=args.warmup_ratio,
571
+ weight_decay=args.weight_decay,
572
+ logging_steps=args.logging_steps,
573
+ save_total_limit=args.save_total_limit,
574
+ seed=args.seed,
575
+ assistant_only_loss=args.assistant_only_loss,
576
+ lora_r=args.lora_r,
577
+ lora_alpha=args.lora_alpha,
578
+ lora_dropout=args.lora_dropout,
579
  )
580
 
581
 
 
585
  parser.add_argument("--run-name", default=DEFAULT_RUN_NAME)
586
  parser.add_argument("--base-model", default=DEFAULT_BASE_MODEL)
587
  parser.add_argument("--max-steps", type=int, default=80)
588
+ parser.add_argument("--num-train-epochs", type=float, default=3.0)
589
  parser.add_argument("--learning-rate", type=float, default=2e-4)
590
  parser.add_argument("--max-seq-length", type=int, default=1024)
591
+ parser.add_argument("--per-device-train-batch-size", type=int, default=1)
592
+ parser.add_argument("--gradient-accumulation-steps", type=int, default=4)
593
+ parser.add_argument("--eval-ratio", type=float, default=0.1)
594
+ parser.add_argument("--eval-steps", type=int, default=10)
595
+ parser.add_argument("--warmup-ratio", type=float, default=0.03)
596
+ parser.add_argument("--weight-decay", type=float, default=0.0)
597
+ parser.add_argument("--logging-steps", type=int, default=5)
598
+ parser.add_argument("--save-total-limit", type=int, default=2)
599
+ parser.add_argument("--seed", type=int, default=42)
600
+ parser.add_argument("--assistant-only-loss", action=argparse.BooleanOptionalAction, default=True)
601
+ parser.add_argument("--lora-r", type=int, default=16)
602
+ parser.add_argument("--lora-alpha", type=int, default=32)
603
+ parser.add_argument("--lora-dropout", type=float, default=0.05)
604
  parser.add_argument("--dry-run", action="store_true")
605
  return parser.parse_args(argv)
606
 
 
655
  run_name: str = DEFAULT_RUN_NAME,
656
  base_model: str = DEFAULT_BASE_MODEL,
657
  max_steps: int = 80,
658
+ num_train_epochs: float = 3.0,
659
  learning_rate: float = 2e-4,
660
  max_seq_length: int = 1024,
661
+ per_device_train_batch_size: int = 1,
662
+ gradient_accumulation_steps: int = 4,
663
+ eval_ratio: float = 0.1,
664
+ eval_steps: int = 10,
665
+ warmup_ratio: float = 0.03,
666
+ weight_decay: float = 0.0,
667
+ logging_steps: int = 5,
668
+ save_total_limit: int = 2,
669
+ seed: int = 42,
670
+ assistant_only_loss: bool = True,
671
+ lora_r: int = 16,
672
+ lora_alpha: int = 32,
673
+ lora_dropout: float = 0.05,
674
  dry_run: bool = False,
675
  ) -> None:
676
  result = run_training_entrypoint(
 
679
  run_name=run_name,
680
  base_model=base_model,
681
  max_steps=max_steps,
682
+ num_train_epochs=num_train_epochs,
683
  learning_rate=learning_rate,
684
  max_seq_length=max_seq_length,
685
+ per_device_train_batch_size=per_device_train_batch_size,
686
+ gradient_accumulation_steps=gradient_accumulation_steps,
687
+ eval_ratio=eval_ratio,
688
+ eval_steps=eval_steps,
689
+ warmup_ratio=warmup_ratio,
690
+ weight_decay=weight_decay,
691
+ logging_steps=logging_steps,
692
+ save_total_limit=save_total_limit,
693
+ seed=seed,
694
+ assistant_only_loss=assistant_only_loss,
695
+ lora_r=lora_r,
696
+ lora_alpha=lora_alpha,
697
+ lora_dropout=lora_dropout,
698
  ),
699
  dry_run=dry_run,
700
  allow_remote=True,
scripts/merge_lora_adapter.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Merge an Objectverse Diary LoRA adapter into its base Hugging Face model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ ADAPTER_WEIGHT_FILES = ("adapter_model.safetensors", "adapter_model.bin")
12
+
13
+
14
+ def validate_adapter_source(adapter: str | Path, *, base_model: str) -> dict[str, object]:
15
+ adapter_text = str(adapter)
16
+ adapter_path = Path(adapter_text)
17
+ if adapter_path.exists():
18
+ if not adapter_path.is_dir():
19
+ raise ValueError(f"Adapter path is not a directory: {adapter_path}")
20
+ config_path = adapter_path / "adapter_config.json"
21
+ if not config_path.exists():
22
+ raise ValueError(f"Adapter directory is missing adapter_config.json: {adapter_path}")
23
+ if not any((adapter_path / name).exists() for name in ADAPTER_WEIGHT_FILES):
24
+ raise ValueError(
25
+ "Adapter directory is missing adapter_model.safetensors or adapter_model.bin."
26
+ )
27
+ config = _read_adapter_config(config_path)
28
+ configured_base = config.get("base_model_name_or_path")
29
+ if configured_base and str(configured_base) != base_model:
30
+ raise ValueError(
31
+ f"Adapter base model is {configured_base!r}, expected {base_model!r}."
32
+ )
33
+ return {
34
+ "adapter": str(adapter_path),
35
+ "adapter_type": "local",
36
+ "adapter_base_model": configured_base or "",
37
+ }
38
+
39
+ if "/" not in adapter_text:
40
+ raise FileNotFoundError(f"Adapter source does not exist: {adapter_text}")
41
+ return {
42
+ "adapter": adapter_text,
43
+ "adapter_type": "hub",
44
+ "adapter_base_model": "",
45
+ }
46
+
47
+
48
+ def plan_merge(
49
+ *,
50
+ base_model: str,
51
+ adapter: str | Path,
52
+ output: Path,
53
+ dry_run: bool,
54
+ ) -> dict[str, object]:
55
+ summary = validate_adapter_source(adapter, base_model=base_model)
56
+ summary.update(
57
+ {
58
+ "base_model": base_model,
59
+ "output": str(output),
60
+ "dry_run": dry_run,
61
+ }
62
+ )
63
+ if dry_run:
64
+ summary["merged"] = False
65
+ return summary
66
+
67
+ merge_lora_adapter(
68
+ base_model=base_model,
69
+ adapter=str(adapter),
70
+ output=output,
71
+ )
72
+ summary["merged"] = True
73
+ summary["files"] = sorted(path.name for path in output.iterdir() if path.is_file())
74
+ return summary
75
+
76
+
77
+ def merge_lora_adapter(
78
+ *,
79
+ base_model: str,
80
+ adapter: str,
81
+ output: Path,
82
+ ) -> None:
83
+ from peft import PeftModel
84
+ from transformers import AutoModelForCausalLM, AutoTokenizer
85
+
86
+ output.mkdir(parents=True, exist_ok=True)
87
+ model = AutoModelForCausalLM.from_pretrained(
88
+ base_model,
89
+ torch_dtype="auto",
90
+ device_map={"": "cpu"},
91
+ low_cpu_mem_usage=True,
92
+ )
93
+ peft_model = PeftModel.from_pretrained(model, adapter)
94
+ merged = peft_model.merge_and_unload(safe_merge=True)
95
+ merged.save_pretrained(
96
+ output,
97
+ safe_serialization=True,
98
+ max_shard_size="2GB",
99
+ )
100
+
101
+ tokenizer = AutoTokenizer.from_pretrained(adapter if Path(adapter).exists() else base_model)
102
+ tokenizer.save_pretrained(output)
103
+
104
+ metadata = {
105
+ "base_model": base_model,
106
+ "adapter": adapter,
107
+ "output": str(output),
108
+ "format": "merged-hf",
109
+ }
110
+ (output / "objectverse_merge_metadata.json").write_text(
111
+ json.dumps(metadata, indent=2, sort_keys=True),
112
+ encoding="utf-8",
113
+ )
114
+
115
+
116
+ def _read_adapter_config(config_path: Path) -> dict[str, object]:
117
+ try:
118
+ payload = json.loads(config_path.read_text(encoding="utf-8"))
119
+ except json.JSONDecodeError as exc:
120
+ raise ValueError(f"Invalid adapter_config.json: {exc.msg}") from exc
121
+ if not isinstance(payload, dict):
122
+ raise ValueError("adapter_config.json must contain a JSON object.")
123
+ return payload
124
+
125
+
126
+ def _print_json(payload: dict[str, Any]) -> None:
127
+ print(json.dumps(payload, indent=2, sort_keys=True), flush=True)
128
+
129
+
130
+ def _parse_args() -> argparse.Namespace:
131
+ parser = argparse.ArgumentParser(description=__doc__)
132
+ parser.add_argument("--base-model", required=True)
133
+ parser.add_argument("--adapter", required=True)
134
+ parser.add_argument("--output", type=Path, required=True)
135
+ parser.add_argument("--dry-run", action="store_true")
136
+ return parser.parse_args()
137
+
138
+
139
+ def main() -> None:
140
+ args = _parse_args()
141
+ _print_json(
142
+ plan_merge(
143
+ base_model=args.base_model,
144
+ adapter=args.adapter,
145
+ output=args.output,
146
+ dry_run=args.dry_run,
147
+ )
148
+ )
149
+
150
+
151
+ if __name__ == "__main__":
152
+ try:
153
+ main()
154
+ except Exception as exc:
155
+ raise SystemExit(str(exc)) from exc
scripts/prepare_curated_dataset.py CHANGED
@@ -16,8 +16,11 @@ from src.models.schema import DiaryEntry, ObjectInfo, ObjectUnderstanding, Perso
16
 
17
 
18
  DEFAULT_OUTPUT_PATH = Path("data/train/objectverse_sft_curated.jsonl")
 
19
  DEFAULT_COUNT = 50
20
- SOURCE = "objectverse-diary-synthetic-curated-v1"
 
 
21
 
22
  SYSTEM_PROMPT = (
23
  "You are Objectverse Diary, an English-first small-model assistant. "
@@ -91,6 +94,226 @@ OBJECTS = [
91
  },
92
  ]
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  MODE_PROFILES = {
95
  "Cynical": {
96
  "mood": "tired but sharply observant",
@@ -125,15 +348,24 @@ MODE_PROFILES = {
125
  }
126
 
127
 
128
- def build_curated_records(count: int = DEFAULT_COUNT) -> list[dict[str, object]]:
 
 
 
 
 
 
 
129
  if count < 1:
130
  raise ValueError("count must be at least 1")
131
 
 
 
132
  records: list[dict[str, object]] = []
133
  for index in range(count):
134
- obj = OBJECTS[index % len(OBJECTS)]
135
- mode = MODES[(index // len(OBJECTS)) % len(MODES)]
136
- record_id = f"curated-synthetic-{index + 1:04d}"
137
  understanding = _build_object_understanding(obj)
138
  persona = _build_persona(obj, mode)
139
  diary = _build_diary(obj, mode, persona.persona, index)
@@ -141,31 +373,29 @@ def build_curated_records(count: int = DEFAULT_COUNT) -> list[dict[str, object]]
141
  "persona": persona.persona.model_dump(mode="json"),
142
  "diary": diary.model_dump(mode="json"),
143
  }
144
- records.append(
145
- {
146
- "id": record_id,
147
- "source": SOURCE,
148
- "split": "train",
149
- "mode": mode,
150
- "object_description": _object_description(obj),
151
- "object_understanding": understanding.model_dump(mode="json"),
152
- "curation_notes": (
153
- "Synthetic curated row: no private photo, no personal identifier, "
154
- "English-first output with Chinese helper text."
155
- ),
156
- "messages": [
157
- {"role": "system", "content": SYSTEM_PROMPT},
158
- {
159
- "role": "user",
160
- "content": _user_prompt(understanding.model_dump(mode="json"), mode),
161
- },
162
- {
163
- "role": "assistant",
164
- "content": json.dumps(assistant_payload, ensure_ascii=False),
165
- },
166
- ],
167
- }
168
- )
169
  return records
170
 
171
 
@@ -176,8 +406,48 @@ def write_jsonl(records: Sequence[Mapping[str, object]], output_path: Path) -> P
176
  return output_path
177
 
178
 
179
- def prepare_curated_dataset(output_path: Path = DEFAULT_OUTPUT_PATH, count: int = DEFAULT_COUNT) -> Path:
180
- return write_jsonl(build_curated_records(count), output_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
 
183
  def _build_object_understanding(obj: Mapping[str, object]) -> ObjectUnderstanding:
@@ -212,18 +482,19 @@ def _build_diary(obj: Mapping[str, object], mode: str, persona: Persona, index:
212
  profile = MODE_PROFILES[mode]
213
  object_name = str(obj["name"])
214
  features = ", ".join(str(feature) for feature in obj["features"][:2])
 
215
  day_number = 300 + index + len(object_name)
216
  english = (
217
  f"Today I waited in the {obj['context']} wearing my {features} like official records. "
218
  f"The humans moved around me with the confidence of temporary weather. "
219
  f"I remembered how I {obj['memory']}, and I answered in my own way: {profile['voice']}. "
220
- f"My mood is {persona.mood}, but I am still here, collecting proof that ordinary things notice everything."
221
  )
222
  chinese = (
223
  f"今天我待在 {obj['context']},带着 {features},像一份安静的档案。"
224
  f"人类从我身边经过,好像自己不是短暂天气。"
225
  f"我记得自己曾经 {obj['memory']},于是用自己的方式回应:{profile['voice']}。"
226
- f"我的情绪是 {persona.mood},但我仍在这里,记录普通物品也会注意到的一切。"
227
  )
228
  return DiaryEntry(
229
  title=f"Secret Diary - Day {day_number}",
@@ -246,7 +517,10 @@ def _character_name(object_name: str, mode: str) -> str:
246
 
247
  def _object_description(obj: Mapping[str, object]) -> str:
248
  features = ", ".join(str(feature) for feature in obj["features"])
249
- return f"{obj['name']} in a {obj['context']} with {features}"
 
 
 
250
 
251
 
252
  def _user_prompt(object_understanding: Mapping[str, object], mode: str) -> str:
@@ -260,15 +534,17 @@ def _user_prompt(object_understanding: Mapping[str, object], mode: str) -> str:
260
 
261
  def _parse_args() -> argparse.Namespace:
262
  parser = argparse.ArgumentParser(description=__doc__)
263
- parser.add_argument("--count", type=int, default=DEFAULT_COUNT)
264
- parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH)
 
265
  return parser.parse_args()
266
 
267
 
268
  def main() -> None:
269
  args = _parse_args()
270
- output_path = prepare_curated_dataset(args.output, args.count)
271
- print(f"wrote {args.count} synthetic curated SFT records to {output_path}")
 
272
 
273
 
274
  if __name__ == "__main__":
 
16
 
17
 
18
  DEFAULT_OUTPUT_PATH = Path("data/train/objectverse_sft_curated.jsonl")
19
+ DEFAULT_V2_OUTPUT_PATH = Path("data/train/objectverse_sft_curated_v2.jsonl")
20
  DEFAULT_COUNT = 50
21
+ DEFAULT_V2_COUNT = 200
22
+ SOURCE_V1 = "objectverse-diary-synthetic-curated-v1"
23
+ SOURCE_V2 = "objectverse-diary-synthetic-curated-v2"
24
 
25
  SYSTEM_PROMPT = (
26
  "You are Objectverse Diary, an English-first small-model assistant. "
 
94
  },
95
  ]
96
 
97
+ OBJECTS_V2 = [
98
+ *(
99
+ dict(
100
+ obj,
101
+ scene_detail=f"resting in the {obj['context']} with a history no one inventoried",
102
+ )
103
+ for obj in OBJECTS
104
+ ),
105
+ {
106
+ "name": "wireless earbud case",
107
+ "features": ["rounded white shell", "tiny hinge", "charging light"],
108
+ "context": "commuter bag",
109
+ "memory": "held two small arguments against silence through a crowded train",
110
+ "scene_detail": "buried beside lint, receipts, and one forgotten mint",
111
+ },
112
+ {
113
+ "name": "transit card",
114
+ "features": ["scuffed plastic", "faded corner", "thin blue stripe"],
115
+ "context": "wallet slot",
116
+ "memory": "opened gates for mornings that were already late",
117
+ "scene_detail": "pressed flat under coins and expired coupons",
118
+ },
119
+ {
120
+ "name": "canvas tote bag",
121
+ "features": ["creased cotton", "ink logo", "soft handles"],
122
+ "context": "entryway floor",
123
+ "memory": "carried groceries, books, and ambitions heavier than both",
124
+ "scene_detail": "slumped open with a receipt still clinging inside",
125
+ },
126
+ {
127
+ "name": "cracked phone case",
128
+ "features": ["clear plastic", "corner crack", "fingerprint haze"],
129
+ "context": "bedside table",
130
+ "memory": "took the impact so the glowing rectangle could remain innocent",
131
+ "scene_detail": "lying face down after another nervous scroll",
132
+ },
133
+ {
134
+ "name": "lip balm tube",
135
+ "features": ["twisted cap", "pocket scratches", "worn label"],
136
+ "context": "coat pocket",
137
+ "memory": "answered every small weather emergency without being thanked",
138
+ "scene_detail": "rolling between keys and a folded train ticket",
139
+ },
140
+ {
141
+ "name": "medicine organizer",
142
+ "features": ["clear lids", "weekday letters", "plastic hinges"],
143
+ "context": "bathroom shelf",
144
+ "memory": "sorted tiny promises into seven obedient compartments",
145
+ "scene_detail": "waiting under fluorescent light with Monday already open",
146
+ },
147
+ {
148
+ "name": "travel toothbrush",
149
+ "features": ["folding handle", "blue bristles", "vented cap"],
150
+ "context": "hotel sink",
151
+ "memory": "kept a mouth honest in rooms that forgot every guest",
152
+ "scene_detail": "balanced near a wrapped soap and a paper cup",
153
+ },
154
+ {
155
+ "name": "passport cover",
156
+ "features": ["navy leather", "creased spine", "stitched edge"],
157
+ "context": "carry-on pocket",
158
+ "memory": "guarded borders, delays, and a face trying to look awake",
159
+ "scene_detail": "wedged beside boarding papers and a silent pen",
160
+ },
161
+ {
162
+ "name": "boarding pass stub",
163
+ "features": ["thermal paper", "torn edge", "gate code"],
164
+ "context": "jacket pocket",
165
+ "memory": "proved a journey happened after the airport swallowed the day",
166
+ "scene_detail": "softened by rain and folded into four tired rectangles",
167
+ },
168
+ {
169
+ "name": "hotel keycard",
170
+ "features": ["matte plastic", "blank stripe", "room-number sleeve"],
171
+ "context": "nightstand",
172
+ "memory": "opened a temporary room for a temporary version of its human",
173
+ "scene_detail": "resting beside a glass of water no one trusted",
174
+ },
175
+ {
176
+ "name": "remote control",
177
+ "features": ["rubber buttons", "battery door scar", "dusty edges"],
178
+ "context": "sofa cushion",
179
+ "memory": "changed channels while nobody changed their mind",
180
+ "scene_detail": "half-sunk between cushions with one crumb for company",
181
+ },
182
+ {
183
+ "name": "reading glasses",
184
+ "features": ["thin frames", "smudged lenses", "bent temple"],
185
+ "context": "book stack",
186
+ "memory": "made small letters confess their meaning at midnight",
187
+ "scene_detail": "left open across a page that was never finished",
188
+ },
189
+ {
190
+ "name": "glasses case",
191
+ "features": ["hard shell", "soft lining", "snap hinge"],
192
+ "context": "desk drawer",
193
+ "memory": "protected fragile clarity from the tyranny of keys",
194
+ "scene_detail": "waiting in darkness with a paperclip pressed to its side",
195
+ },
196
+ {
197
+ "name": "wristwatch",
198
+ "features": ["scratched face", "brown strap", "small crown"],
199
+ "context": "dresser tray",
200
+ "memory": "measured days while humans pretended not to be measured",
201
+ "scene_detail": "stopped beside coins and a single loose button",
202
+ },
203
+ {
204
+ "name": "hair clip",
205
+ "features": ["amber plastic", "tiny teeth", "curved spring"],
206
+ "context": "bathroom counter",
207
+ "memory": "held chaos together for meetings, errands, and almost-crying",
208
+ "scene_detail": "resting near a fogged mirror and stray strands",
209
+ },
210
+ {
211
+ "name": "laundry token",
212
+ "features": ["round brass", "machine number", "dulled rim"],
213
+ "context": "laundry room",
214
+ "memory": "bought one more spin for clothes that knew too much",
215
+ "scene_detail": "cool in a palm smelling faintly of detergent",
216
+ },
217
+ {
218
+ "name": "refrigerator magnet",
219
+ "features": ["painted souvenir", "flat magnet back", "chipped corner"],
220
+ "context": "kitchen door",
221
+ "memory": "held reminders in place while everyone forgot the reason",
222
+ "scene_detail": "pinning a grocery list under a blue-white hum",
223
+ },
224
+ {
225
+ "name": "grocery receipt",
226
+ "features": ["curled paper", "faded ink", "long total"],
227
+ "context": "kitchen counter",
228
+ "memory": "itemized hunger, soap, and one unnecessary chocolate bar",
229
+ "scene_detail": "curling beside fruit that ripened too quickly",
230
+ },
231
+ {
232
+ "name": "spice jar",
233
+ "features": ["glass body", "red powder", "metal lid"],
234
+ "context": "kitchen shelf",
235
+ "memory": "made bland evenings briefly remember a warmer country",
236
+ "scene_detail": "standing in a row of louder labels",
237
+ },
238
+ {
239
+ "name": "cutting board",
240
+ "features": ["wood grain", "knife marks", "rounded corner"],
241
+ "context": "kitchen island",
242
+ "memory": "received every chopped plan without flinching",
243
+ "scene_detail": "drying upright after a meal nobody photographed",
244
+ },
245
+ {
246
+ "name": "ceramic bowl",
247
+ "features": ["blue rim", "tiny chip", "glazed curve"],
248
+ "context": "dish rack",
249
+ "memory": "held soup, cereal, and one quiet apology",
250
+ "scene_detail": "tilted beside plates still warm from rinse water",
251
+ },
252
+ {
253
+ "name": "reusable chopsticks",
254
+ "features": ["dark bamboo", "tapered tips", "cloth sleeve"],
255
+ "context": "lunch bag",
256
+ "memory": "lifted noodles through ordinary hunger and office gossip",
257
+ "scene_detail": "tucked into a sleeve with a soy sauce stain",
258
+ },
259
+ {
260
+ "name": "tea tin",
261
+ "features": ["green metal", "tight lid", "leaf dust"],
262
+ "context": "pantry shelf",
263
+ "memory": "kept rain-colored leaves ready for small recoveries",
264
+ "scene_detail": "quiet behind cereal boxes and a jar of almonds",
265
+ },
266
+ {
267
+ "name": "sticky note stack",
268
+ "features": ["yellow pages", "curled edge", "faint adhesive"],
269
+ "context": "monitor base",
270
+ "memory": "accepted urgent thoughts that became decorative fossils",
271
+ "scene_detail": "leaning under a monitor's cold rectangular sun",
272
+ },
273
+ {
274
+ "name": "binder clip",
275
+ "features": ["black steel", "silver arms", "pinched mouth"],
276
+ "context": "paper tray",
277
+ "memory": "held loose pages together when ideas tried to scatter",
278
+ "scene_detail": "biting a stack marked later in blue ink",
279
+ },
280
+ {
281
+ "name": "fountain pen",
282
+ "features": ["black barrel", "gold nib", "ink stain"],
283
+ "context": "notebook margin",
284
+ "memory": "turned hesitation into lines that looked deliberate",
285
+ "scene_detail": "uncapped beside a sentence crossed out twice",
286
+ },
287
+ {
288
+ "name": "old ticket stub",
289
+ "features": ["creased paper", "seat number", "torn perforation"],
290
+ "context": "memory box",
291
+ "memory": "survived the event after the applause became dust",
292
+ "scene_detail": "pressed under postcards and a dried ribbon",
293
+ },
294
+ {
295
+ "name": "candle jar",
296
+ "features": ["smoked glass", "wax tunnel", "blackened wick"],
297
+ "context": "window ledge",
298
+ "memory": "made one room pretend to be softer than it was",
299
+ "scene_detail": "cooled beside a window with rain on the other side",
300
+ },
301
+ {
302
+ "name": "alarm clock",
303
+ "features": ["round face", "plastic feet", "stubborn button"],
304
+ "context": "bedside shelf",
305
+ "memory": "tore people from dreams and was hated for being correct",
306
+ "scene_detail": "facing a bed that negotiated with every morning",
307
+ },
308
+ {
309
+ "name": "tape measure",
310
+ "features": ["yellow tape", "lock switch", "metal hook"],
311
+ "context": "tool drawer",
312
+ "memory": "proved shelves, windows, and ambitions were smaller than claimed",
313
+ "scene_detail": "coiled beside screws and one pencil shaved short",
314
+ },
315
+ ]
316
+
317
  MODE_PROFILES = {
318
  "Cynical": {
319
  "mood": "tired but sharply observant",
 
348
  }
349
 
350
 
351
+ def build_curated_records(
352
+ count: int | None = None,
353
+ *,
354
+ version: str = "v1",
355
+ ) -> list[dict[str, object]]:
356
+ version = _validate_version(version)
357
+ if count is None:
358
+ count = DEFAULT_V2_COUNT if version == "v2" else DEFAULT_COUNT
359
  if count < 1:
360
  raise ValueError("count must be at least 1")
361
 
362
+ objects = _objects_for_version(version)
363
+ source = _source_for_version(version)
364
  records: list[dict[str, object]] = []
365
  for index in range(count):
366
+ obj = objects[index % len(objects)]
367
+ mode = MODES[(index // len(objects)) % len(MODES)]
368
+ record_id = _record_id(version, index)
369
  understanding = _build_object_understanding(obj)
370
  persona = _build_persona(obj, mode)
371
  diary = _build_diary(obj, mode, persona.persona, index)
 
373
  "persona": persona.persona.model_dump(mode="json"),
374
  "diary": diary.model_dump(mode="json"),
375
  }
376
+ record = {
377
+ "id": record_id,
378
+ "source": source,
379
+ "split": "train",
380
+ "mode": mode,
381
+ "object_description": _object_description(obj),
382
+ "object_understanding": understanding.model_dump(mode="json"),
383
+ "curation_notes": _curation_notes(version),
384
+ "messages": [
385
+ {"role": "system", "content": SYSTEM_PROMPT},
386
+ {
387
+ "role": "user",
388
+ "content": _user_prompt(understanding.model_dump(mode="json"), mode),
389
+ },
390
+ {
391
+ "role": "assistant",
392
+ "content": json.dumps(assistant_payload, ensure_ascii=False),
393
+ },
394
+ ],
395
+ }
396
+ if version == "v2":
397
+ record["scene_detail"] = str(obj["scene_detail"])
398
+ records.append(record)
 
 
399
  return records
400
 
401
 
 
406
  return output_path
407
 
408
 
409
+ def prepare_curated_dataset(
410
+ output_path: Path | None = None,
411
+ count: int | None = None,
412
+ *,
413
+ version: str = "v1",
414
+ ) -> Path:
415
+ version = _validate_version(version)
416
+ if output_path is None:
417
+ output_path = DEFAULT_V2_OUTPUT_PATH if version == "v2" else DEFAULT_OUTPUT_PATH
418
+ return write_jsonl(build_curated_records(count, version=version), output_path)
419
+
420
+
421
+ def _validate_version(version: str) -> str:
422
+ if version not in {"v1", "v2"}:
423
+ raise ValueError("version must be 'v1' or 'v2'.")
424
+ return version
425
+
426
+
427
+ def _objects_for_version(version: str) -> Sequence[Mapping[str, object]]:
428
+ return OBJECTS_V2 if version == "v2" else OBJECTS
429
+
430
+
431
+ def _source_for_version(version: str) -> str:
432
+ return SOURCE_V2 if version == "v2" else SOURCE_V1
433
+
434
+
435
+ def _record_id(version: str, index: int) -> str:
436
+ if version == "v2":
437
+ return f"curated-v2-synthetic-{index + 1:04d}"
438
+ return f"curated-synthetic-{index + 1:04d}"
439
+
440
+
441
+ def _curation_notes(version: str) -> str:
442
+ if version == "v2":
443
+ return (
444
+ "Synthetic curated v2 row: no private photo, no personal identifier, "
445
+ "broader object and scene coverage, English-first output with Chinese helper text."
446
+ )
447
+ return (
448
+ "Synthetic curated row: no private photo, no personal identifier, "
449
+ "English-first output with Chinese helper text."
450
+ )
451
 
452
 
453
  def _build_object_understanding(obj: Mapping[str, object]) -> ObjectUnderstanding:
 
482
  profile = MODE_PROFILES[mode]
483
  object_name = str(obj["name"])
484
  features = ", ".join(str(feature) for feature in obj["features"][:2])
485
+ scene = str(obj.get("scene_detail", "collecting proof that ordinary things notice everything"))
486
  day_number = 300 + index + len(object_name)
487
  english = (
488
  f"Today I waited in the {obj['context']} wearing my {features} like official records. "
489
  f"The humans moved around me with the confidence of temporary weather. "
490
  f"I remembered how I {obj['memory']}, and I answered in my own way: {profile['voice']}. "
491
+ f"My mood is {persona.mood}, but I am still here, {scene}."
492
  )
493
  chinese = (
494
  f"今天我待在 {obj['context']},带着 {features},像一份安静的档案。"
495
  f"人类从我身边经过,好像自己不是短暂天气。"
496
  f"我记得自己曾经 {obj['memory']},于是用自己的方式回应:{profile['voice']}。"
497
+ f"我的情绪是 {persona.mood},但我仍在这里,{scene}。"
498
  )
499
  return DiaryEntry(
500
  title=f"Secret Diary - Day {day_number}",
 
517
 
518
  def _object_description(obj: Mapping[str, object]) -> str:
519
  features = ", ".join(str(feature) for feature in obj["features"])
520
+ description = f"{obj['name']} in a {obj['context']} with {features}"
521
+ if "scene_detail" in obj:
522
+ description = f"{description}, {obj['scene_detail']}"
523
+ return description
524
 
525
 
526
  def _user_prompt(object_understanding: Mapping[str, object], mode: str) -> str:
 
534
 
535
  def _parse_args() -> argparse.Namespace:
536
  parser = argparse.ArgumentParser(description=__doc__)
537
+ parser.add_argument("--version", choices=("v1", "v2"), default="v1")
538
+ parser.add_argument("--count", type=int, default=None)
539
+ parser.add_argument("--output", type=Path, default=None)
540
  return parser.parse_args()
541
 
542
 
543
  def main() -> None:
544
  args = _parse_args()
545
+ output_path = prepare_curated_dataset(args.output, args.count, version=args.version)
546
+ record_count = args.count or (DEFAULT_V2_COUNT if args.version == "v2" else DEFAULT_COUNT)
547
+ print(f"wrote {record_count} synthetic curated SFT records to {output_path}")
548
 
549
 
550
  if __name__ == "__main__":
scripts/publish_hf_dataset.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload a curated Objectverse Diary SFT JSONL file to Hugging Face Datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def validate_dataset_file(dataset_file: Path) -> dict[str, object]:
12
+ if not dataset_file.exists() or not dataset_file.is_file():
13
+ raise FileNotFoundError(f"Dataset file does not exist: {dataset_file}")
14
+
15
+ record_count = 0
16
+ sources: set[str] = set()
17
+ modes: set[str] = set()
18
+ object_names: set[str] = set()
19
+ for line_number, line in enumerate(
20
+ dataset_file.read_text(encoding="utf-8").splitlines(),
21
+ start=1,
22
+ ):
23
+ if not line.strip():
24
+ continue
25
+ try:
26
+ record = json.loads(line)
27
+ except json.JSONDecodeError as exc:
28
+ raise ValueError(f"Invalid JSON on line {line_number}: {exc.msg}") from exc
29
+ if not isinstance(record, dict):
30
+ raise ValueError(f"Line {line_number} must be a JSON object.")
31
+ messages = record.get("messages")
32
+ if not isinstance(messages, list) or not messages:
33
+ raise ValueError(f"Line {line_number} must include a non-empty messages list.")
34
+ assistant_messages = [
35
+ message
36
+ for message in messages
37
+ if isinstance(message, dict) and message.get("role") == "assistant"
38
+ ]
39
+ if not assistant_messages:
40
+ raise ValueError(f"Line {line_number} must include an assistant message.")
41
+ assistant_content = assistant_messages[-1].get("content")
42
+ if not isinstance(assistant_content, str):
43
+ raise ValueError(f"Line {line_number} assistant content must be a string.")
44
+ try:
45
+ assistant_payload = json.loads(assistant_content)
46
+ except json.JSONDecodeError as exc:
47
+ raise ValueError(
48
+ f"Line {line_number} assistant content is not valid JSON: {exc.msg}"
49
+ ) from exc
50
+ if not isinstance(assistant_payload, dict):
51
+ raise ValueError(f"Line {line_number} assistant content must be a JSON object.")
52
+ if "persona" not in assistant_payload or "diary" not in assistant_payload:
53
+ raise ValueError(
54
+ f"Line {line_number} assistant content must include persona and diary."
55
+ )
56
+
57
+ record_count += 1
58
+ if isinstance(record.get("source"), str):
59
+ sources.add(str(record["source"]))
60
+ if isinstance(record.get("mode"), str):
61
+ modes.add(str(record["mode"]))
62
+ object_understanding = record.get("object_understanding")
63
+ if isinstance(object_understanding, dict):
64
+ raw_object = object_understanding.get("object")
65
+ if isinstance(raw_object, dict) and isinstance(raw_object.get("name"), str):
66
+ object_names.add(str(raw_object["name"]))
67
+
68
+ if record_count == 0:
69
+ raise ValueError(f"Dataset file has no records: {dataset_file}")
70
+
71
+ return {
72
+ "dataset_file": str(dataset_file),
73
+ "record_count": record_count,
74
+ "sources": sorted(sources),
75
+ "modes": sorted(modes),
76
+ "unique_object_count": len(object_names),
77
+ }
78
+
79
+
80
+ def upload_dataset(
81
+ *,
82
+ dataset_file: Path,
83
+ repo_id: str,
84
+ path_in_repo: str,
85
+ private: bool,
86
+ commit_message: str,
87
+ dry_run: bool,
88
+ ) -> dict[str, object]:
89
+ summary = validate_dataset_file(dataset_file)
90
+ summary.update(
91
+ {
92
+ "repo_id": repo_id,
93
+ "path_in_repo": path_in_repo,
94
+ "private": private,
95
+ "commit_message": commit_message,
96
+ "dry_run": dry_run,
97
+ }
98
+ )
99
+ if dry_run:
100
+ summary["uploaded"] = False
101
+ return summary
102
+
103
+ from huggingface_hub import HfApi
104
+
105
+ api = HfApi()
106
+ api.create_repo(repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True)
107
+ api.upload_file(
108
+ path_or_fileobj=str(dataset_file),
109
+ path_in_repo=path_in_repo,
110
+ repo_id=repo_id,
111
+ repo_type="dataset",
112
+ commit_message=commit_message,
113
+ )
114
+ summary["uploaded"] = True
115
+ summary["url"] = f"https://huggingface.co/datasets/{repo_id}"
116
+ return summary
117
+
118
+
119
+ def _print_json(payload: dict[str, Any]) -> None:
120
+ print(json.dumps(payload, indent=2, sort_keys=True), flush=True)
121
+
122
+
123
+ def _parse_args() -> argparse.Namespace:
124
+ parser = argparse.ArgumentParser(description=__doc__)
125
+ parser.add_argument("--dataset-file", type=Path, required=True)
126
+ parser.add_argument("--repo-id", required=True)
127
+ parser.add_argument("--path-in-repo", required=True)
128
+ parser.add_argument("--private", action="store_true")
129
+ parser.add_argument(
130
+ "--commit-message",
131
+ default="Upload Objectverse Diary curated SFT dataset",
132
+ )
133
+ parser.add_argument("--dry-run", action="store_true")
134
+ return parser.parse_args()
135
+
136
+
137
+ def main() -> None:
138
+ args = _parse_args()
139
+ _print_json(
140
+ upload_dataset(
141
+ dataset_file=args.dataset_file,
142
+ repo_id=args.repo_id,
143
+ path_in_repo=args.path_in_repo,
144
+ private=args.private,
145
+ commit_message=args.commit_message,
146
+ dry_run=args.dry_run,
147
+ )
148
+ )
149
+
150
+
151
+ if __name__ == "__main__":
152
+ try:
153
+ main()
154
+ except Exception as exc:
155
+ raise SystemExit(str(exc)) from exc
scripts/publish_hf_gguf.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload an Objectverse Diary GGUF model file to Hugging Face Hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def validate_gguf_file(gguf_file: Path) -> dict[str, object]:
12
+ if not gguf_file.exists() or not gguf_file.is_file():
13
+ raise FileNotFoundError(f"GGUF file does not exist: {gguf_file}")
14
+ if gguf_file.suffix.lower() != ".gguf":
15
+ raise ValueError(f"GGUF file must use .gguf suffix: {gguf_file}")
16
+ size_bytes = gguf_file.stat().st_size
17
+ if size_bytes <= 0:
18
+ raise ValueError(f"GGUF file is empty: {gguf_file}")
19
+ return {
20
+ "gguf_file": str(gguf_file),
21
+ "size_bytes": size_bytes,
22
+ }
23
+
24
+
25
+ def upload_gguf(
26
+ *,
27
+ gguf_file: Path,
28
+ repo_id: str,
29
+ path_in_repo: str,
30
+ private: bool,
31
+ commit_message: str,
32
+ dry_run: bool,
33
+ ) -> dict[str, object]:
34
+ summary = validate_gguf_file(gguf_file)
35
+ summary.update(
36
+ {
37
+ "repo_id": repo_id,
38
+ "path_in_repo": path_in_repo,
39
+ "private": private,
40
+ "commit_message": commit_message,
41
+ "dry_run": dry_run,
42
+ }
43
+ )
44
+ if dry_run:
45
+ summary["uploaded"] = False
46
+ return summary
47
+
48
+ from huggingface_hub import HfApi
49
+
50
+ api = HfApi()
51
+ api.create_repo(repo_id=repo_id, repo_type="model", private=private, exist_ok=True)
52
+ api.upload_file(
53
+ path_or_fileobj=str(gguf_file),
54
+ path_in_repo=path_in_repo,
55
+ repo_id=repo_id,
56
+ repo_type="model",
57
+ commit_message=commit_message,
58
+ )
59
+ summary["uploaded"] = True
60
+ summary["url"] = f"https://huggingface.co/{repo_id}/blob/main/{path_in_repo}"
61
+ return summary
62
+
63
+
64
+ def _print_json(payload: dict[str, Any]) -> None:
65
+ print(json.dumps(payload, indent=2, sort_keys=True), flush=True)
66
+
67
+
68
+ def _parse_args() -> argparse.Namespace:
69
+ parser = argparse.ArgumentParser(description=__doc__)
70
+ parser.add_argument("--gguf-file", type=Path, required=True)
71
+ parser.add_argument("--repo-id", required=True)
72
+ parser.add_argument("--path-in-repo", required=True)
73
+ parser.add_argument("--private", action="store_true")
74
+ parser.add_argument(
75
+ "--commit-message",
76
+ default="Upload Objectverse Diary GGUF model",
77
+ )
78
+ parser.add_argument("--dry-run", action="store_true")
79
+ return parser.parse_args()
80
+
81
+
82
+ def main() -> None:
83
+ args = _parse_args()
84
+ _print_json(
85
+ upload_gguf(
86
+ gguf_file=args.gguf_file,
87
+ repo_id=args.repo_id,
88
+ path_in_repo=args.path_in_repo,
89
+ private=args.private,
90
+ commit_message=args.commit_message,
91
+ dry_run=args.dry_run,
92
+ )
93
+ )
94
+
95
+
96
+ if __name__ == "__main__":
97
+ try:
98
+ main()
99
+ except Exception as exc:
100
+ raise SystemExit(str(exc)) from exc
src/examples.py CHANGED
@@ -52,8 +52,9 @@ def gradio_examples() -> list[list[str]]:
52
 
53
  def example_button_label(index: int) -> str:
54
  item = EXAMPLE_OBJECTS[index]
 
55
  return (
56
  f"{item['archive_id']}\n"
57
- f"{item['label']}\n"
58
  f"{item['mode']} · {item['tags']}"
59
  )
 
52
 
53
  def example_button_label(index: int) -> str:
54
  item = EXAMPLE_OBJECTS[index]
55
+ label = item["label"].split("/")[0].strip()
56
  return (
57
  f"{item['archive_id']}\n"
58
+ f"{label}\n"
59
  f"{item['mode']} · {item['tags']}"
60
  )
src/renderer/share_card.py CHANGED
@@ -15,14 +15,14 @@ def render_share_card(persona: PersonaEnvelope, diary: DiaryEntry) -> str:
15
  <article class="{CARD_WRAPPER_CLASS}">
16
  <header class="card-header">
17
  <div>
18
- <div class="card-kicker">Objectverse Diary / 万物日记</div>
19
  <h2>{escape(p.character_name)}</h2>
20
  </div>
21
  <span class="card-stamp">OBJECT FILE</span>
22
  </header>
23
  <p class="card-object">{escape(p.object_name)} · {escape(p.mood)}</p>
24
  <p class="card-quote">{escape(diary.english)}</p>
25
- <p class="card-cn">{escape(diary.chinese)}</p>
26
  <div class="card-tags">{tags}</div>
27
  </article>
28
  """
 
15
  <article class="{CARD_WRAPPER_CLASS}">
16
  <header class="card-header">
17
  <div>
18
+ <div class="card-kicker">Objectverse Diary <span class="lang-zh">万物日记</span></div>
19
  <h2>{escape(p.character_name)}</h2>
20
  </div>
21
  <span class="card-stamp">OBJECT FILE</span>
22
  </header>
23
  <p class="card-object">{escape(p.object_name)} · {escape(p.mood)}</p>
24
  <p class="card-quote">{escape(diary.english)}</p>
25
+ <p class="card-cn lang-zh block">{escape(diary.chinese)}</p>
26
  <div class="card-tags">{tags}</div>
27
  </article>
28
  """
src/ui/copy.py CHANGED
@@ -1,19 +1,19 @@
1
- """English-first, Chinese-second UI copy."""
2
 
3
  TITLE = "Objectverse Diary"
4
- SUBTITLE = "Every object has a secret life. / 每个物品都有秘密人生。"
5
- UPLOAD_LABEL = "Upload an object photo / 上传一个物品照片"
6
- DESCRIPTION_LABEL = "Optional object description / 可选物品描述"
7
  DESCRIPTION_PLACEHOLDER = "Example: an old white coffee mug on my developer desk"
8
- MODE_LABEL = "Choose a personality mode / 选择人格模式"
9
- GENERATE_LABEL = "Wake the object / 唤醒这个物品"
10
- EXAMPLES_LABEL = "Example objects / 示例物品"
11
- OBJECT_JSON_LABEL = "Object Understanding JSON / 物品识别 JSON"
12
- PERSONA_JSON_LABEL = "Persona JSON / 人格 JSON"
13
- DIARY_LABEL = "Secret Diary / 秘密日记"
14
- SHARE_CARD_LABEL = "Share Card / 分享卡片"
15
- TRACE_JSON_LABEL = "Trace JSON / 模型轨迹 JSON"
16
- TRACE_PATH_LABEL = "Saved trace path / 已保存 trace 路径"
17
- CHAT_LABEL = "Chat with the object / 和物品对话"
18
  CHAT_INPUT_PLACEHOLDER = "Ask the object something..."
19
- CHAT_BUTTON_LABEL = "Ask / 追问"
 
1
+ """English UI copy."""
2
 
3
  TITLE = "Objectverse Diary"
4
+ SUBTITLE = "Every object has a secret life."
5
+ UPLOAD_LABEL = "Upload an object photo"
6
+ DESCRIPTION_LABEL = "Optional object description"
7
  DESCRIPTION_PLACEHOLDER = "Example: an old white coffee mug on my developer desk"
8
+ MODE_LABEL = "Choose a personality mode"
9
+ GENERATE_LABEL = "Wake the object"
10
+ EXAMPLES_LABEL = "Example objects"
11
+ OBJECT_JSON_LABEL = "Object Understanding JSON"
12
+ PERSONA_JSON_LABEL = "Persona JSON"
13
+ DIARY_LABEL = "Secret Diary"
14
+ SHARE_CARD_LABEL = "Share Card"
15
+ TRACE_JSON_LABEL = "Trace JSON"
16
+ TRACE_PATH_LABEL = "Saved trace path"
17
+ CHAT_LABEL = "Chat with the object"
18
  CHAT_INPUT_PLACEHOLDER = "Ask the object something..."
19
+ CHAT_BUTTON_LABEL = "Ask"
src/ui/layout.py CHANGED
@@ -19,37 +19,194 @@ from src.renderer.share_card import render_share_card
19
  from src.ui import copy
20
  from src.utils.zero_gpu import zero_gpu
21
 
22
- CHAT_EMPTY_MESSAGE = "Wake an object first. / 请先唤醒一个物品。"
23
 
24
  OBJECT_FILE_EMPTY = """
25
  <div class="archive-empty">
26
- <span class="archive-label">Object File / 物品档案</span>
27
  <h3>No object awake yet.</h3>
28
- <p>Upload or describe an everyday object to open its secret archive. / 上传或描述一个日常物品后打开秘密档案。</p>
 
29
  </div>
30
  """
31
 
32
  DIARY_EMPTY = """
33
- ### Secret Diary / 秘密日记
34
 
35
- Wake an object to open its diary. / 唤醒物品后阅读它的日记。
 
 
 
 
36
  """
37
 
38
  SHARE_CARD_EMPTY = """
39
  <div class="objectverse-placeholder">
40
- <span>Share Card / 分享卡片</span>
41
  <strong>Waiting for an object file.</strong>
42
- <p>A screenshot-friendly archive card will appear here. / 可截图分享的档案卡片会显示在这里。</p>
 
43
  </div>
44
  """
45
 
46
  TRACE_EMPTY = """
47
  <div class="archive-empty compact">
48
- <span class="archive-label">Trace / 模型轨迹</span>
49
- <p>No trace saved yet. / 尚未保存 trace。</p>
 
50
  </div>
51
  """
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  GenerationUiResult = tuple[
54
  str,
55
  dict[str, Any],
@@ -80,210 +237,194 @@ def build_app() -> gr.Blocks:
80
  background_fill_secondary_dark="rgba(30, 28, 25, 0.6)",
81
  border_color_primary="rgba(212, 175, 55, 0.15)",
82
  border_color_primary_dark="rgba(212, 175, 55, 0.15)",
 
 
 
 
 
 
 
 
 
 
 
 
83
  block_background_fill="transparent",
84
  block_background_fill_dark="transparent",
85
  block_border_width="0px",
 
 
 
 
 
 
86
  panel_background_fill="transparent",
87
  panel_background_fill_dark="transparent",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  )
89
 
90
- with gr.Blocks(theme=custom_theme, head=f"<style>{css}</style>", title=APP_TITLE, fill_width=True, elem_id="objectverse-app") as demo:
91
- with gr.Row(elem_id="app-container"):
92
- # === Sidebar ===
93
- with gr.Column(elem_id="sidebar", scale=0, min_width=240):
94
- gr.HTML(
95
- """
96
- <nav class="sidebar-nav">
97
- <div class="sidebar-logo">
98
- <div class="logo-icon"></div>
99
- <h2>Objectverse<br>Diary</h2>
100
- </div>
101
- <ul class="sidebar-menu">
102
- <li class="active"><a href="#intake">Home</a></li>
103
- <li><a href="#intake">Intake</a></li>
104
- <li><a href="#object-file">Object File</a></li>
105
- <li><a href="#diary">Diary</a></li>
106
- <li><a href="#chat-panel">Chat</a></li>
107
- <li><a href="#share-panel">Share Card</a></li>
108
- <li><a href="#trace">Trace</a></li>
109
- <li><a href="#settings">Settings</a></li>
110
- </ul>
111
- <div class="sidebar-footer">
112
- <div class="footer-stamp">
113
- <small>OBJECTVERSE ARCHIVE</small>
114
- <span>No. 000827</span>
115
- <small>Curate. Converse. Cherish.</small>
116
- </div>
117
- <div class="lang-switch">
118
- <button class="active">EN</button>
119
- <button>中文</button>
120
- </div>
121
- </div>
122
- </nav>
123
- """,
124
- padding=False,
125
- )
126
-
127
- # === Main Content Area ===
128
- with gr.Column(elem_id="main-content", scale=1):
129
- gr.HTML(
130
- f"""
131
- <section id="objectverse-hero">
132
- <div class="hero-copy">
133
- <h1>{APP_TITLE}</h1>
134
- <p class="hero-kicker">Every object has a secret life.<br><span>万物日记:每个物品都有秘密人生</span></p>
135
- </div>
136
- <div class="hero-badges" aria-label="Project constraints">
137
- <span>Small Models</span>
138
- <span>Local-First</span>
139
- <span>No Cloud APIs</span>
140
- </div>
141
- </section>
142
- """,
143
- padding=False,
144
- )
145
-
146
- result_state = gr.State()
147
- zero_gpu_probe_button = gr.Button(visible=False)
148
- zero_gpu_probe_output = gr.JSON(visible=False)
149
- vision_runtime_probe_button = gr.Button(visible=False)
150
- vision_runtime_probe_output = gr.JSON(visible=False)
151
-
152
- # Intake & Examples Row
153
- with gr.Row(elem_id="intake", elem_classes=["content-section"]):
154
- # Left: Intake
155
- with gr.Column(scale=7, elem_classes=["archive-panel", "intake-panel"]):
156
- image_input = gr.Image(
157
- label=copy.UPLOAD_LABEL,
158
- show_label=False,
159
- type="filepath",
160
- sources=["upload"],
161
- elem_id="object-upload",
162
- )
163
- gr.HTML("""<div class="or-divider"><span>OR</span></div>""", padding=False)
164
- description_input = gr.Textbox(
165
- label=copy.DESCRIPTION_LABEL,
166
- placeholder=copy.DESCRIPTION_PLACEHOLDER,
167
- lines=2,
168
- max_lines=5,
169
- elem_id="object-description",
170
- )
171
-
172
- gr.HTML("""<div class="mode-header">Personality mode <small>人格模式</small> <span class="help-icon">?</span></div>""", padding=False)
173
- mode_input = gr.Radio(
174
- label=copy.MODE_LABEL,
175
- show_label=False,
176
- choices=PERSONALITY_MODES,
177
- value=DEFAULT_MODE,
178
- elem_id="personality-mode",
179
- elem_classes=["mode-switch"],
180
- )
181
- generate_button = gr.Button("Wake the Object\n唤醒物品", variant="primary", elem_id="wake-button")
182
-
183
- gr.HTML(
184
- """
185
- <div class="how-it-works">
186
- <div class="step">
187
- <span class="step-num">01</span>
188
- <div class="step-icon img-icon"></div>
189
- <div class="step-text">
190
- <strong>Upload or describe</strong>
191
- <small>上传物品或描述心情</small>
192
- <p>Give me a photo or words—anything that holds a story.</p>
193
- </div>
194
- </div>
195
- <div class="step">
196
- <span class="step-num">02</span>
197
- <div class="step-icon pen-icon"></div>
198
- <div class="step-text">
199
- <strong>I imagine its life</strong>
200
- <small>我为它编织人生</small>
201
- <p>I'll step into its shoes and imagine its secret life.</p>
202
- </div>
203
- </div>
204
- <div class="step">
205
- <span class="step-num">03</span>
206
- <div class="step-icon book-icon"></div>
207
- <div class="step-text">
208
- <strong>Read its diary</strong>
209
- <small>阅读物品日记</small>
210
- <p>Receive a diary entry written from its perspective.</p>
211
- </div>
212
- </div>
213
- </div>
214
- """,
215
- padding=False,
216
- )
217
 
218
- # Right: Examples
219
- with gr.Column(scale=4, elem_classes=["archive-panel", "examples-panel"]):
220
- gr.HTML(
221
- """
222
- <div class="example-header">
223
- <div class="books-icon"></div>
224
- <div>
225
- <strong>Example Objects</strong>
226
- <span>灵感库</span>
227
- </div>
228
- </div>
229
- """,
230
- padding=False,
231
- )
232
- example_buttons: list[gr.Button] = []
233
- for index in range(len(EXAMPLE_OBJECTS)):
234
- example_buttons.append(
235
- gr.Button(
236
- example_button_label(index),
237
- elem_classes=["example-card"],
238
- variant="secondary",
239
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  )
241
- gr.HTML("""<a href="#object-file" class="view-more">View more in Object File →</a>""", padding=False)
242
-
243
- # Object File Section
244
- with gr.Row(elem_id="object-file", elem_classes=["content-section"]):
245
- with gr.Column(scale=1, elem_classes=["archive-panel", "file-panel"]):
246
- gr.HTML(_panel_header("02", "Object File / Recognition", "物品档案", "Structured mock understanding and persona."), padding=False)
247
- object_file_summary = gr.HTML(value=OBJECT_FILE_EMPTY, elem_id="object-file-summary", padding=False)
248
- with gr.Accordion("Raw JSON", open=False):
249
- object_json = gr.JSON(value={}, label=copy.OBJECT_JSON_LABEL)
250
- persona_json = gr.JSON(value={}, label=copy.PERSONA_JSON_LABEL)
251
-
252
- # Diary Section
253
- with gr.Row(elem_id="diary", elem_classes=["content-section"]):
254
- with gr.Column(scale=1, elem_classes=["archive-panel", "diary-panel"]):
255
- gr.HTML(_panel_header("03", "Secret Diary", "秘密日记", "A private note written by the object."), padding=False)
256
- diary_output = gr.Markdown(
257
- value=DIARY_EMPTY,
258
- label=copy.DIARY_LABEL,
259
- elem_id="diary-output",
260
- )
261
-
262
- # Share & Chat Section
263
- with gr.Row(elem_id="share", elem_classes=["content-section", "split-section"]):
264
- with gr.Column(scale=5, elem_classes=["archive-panel", "share-panel", "anchored"], elem_id="share-panel"):
265
- gr.HTML(_panel_header("04", "Share Card", "分享卡片", "Fixed-width card for screenshots."), padding=False)
266
- share_card = gr.HTML(value=SHARE_CARD_EMPTY, label=copy.SHARE_CARD_LABEL, padding=False)
267
-
268
- with gr.Column(scale=4, elem_classes=["archive-panel", "chat-panel", "anchored"], elem_id="chat-panel"):
269
- gr.HTML(_panel_header("05", "Object Chat", "物品对话", "Ask after the object wakes up."), padding=False)
270
- chatbot = gr.Chatbot(
271
- value=_empty_chat_history(),
272
- label=copy.CHAT_LABEL,
273
- type="messages",
274
- height=300,
275
- allow_tags=False,
276
  )
277
- chat_input = gr.Textbox(placeholder=copy.CHAT_INPUT_PLACEHOLDER, show_label=False)
278
- chat_button = gr.Button(copy.CHAT_BUTTON_LABEL, elem_classes=["quiet-button"])
279
 
280
- # Trace Section
281
- with gr.Row(elem_id="trace", elem_classes=["content-section"]):
282
- with gr.Column(scale=1, elem_classes=["archive-panel", "trace-panel"]):
283
- gr.HTML(_panel_header("06", "Trace", "模型轨迹", "Saved JSON record for reproducibility."), padding=False)
284
- trace_summary = gr.HTML(value=TRACE_EMPTY, elem_id="trace-summary", padding=False)
285
- trace_json = gr.JSON(value={}, label=copy.TRACE_JSON_LABEL)
286
- trace_path = gr.Textbox(label=copy.TRACE_PATH_LABEL, interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
  manual_outputs = [
289
  object_file_summary,
@@ -337,12 +478,13 @@ def build_app() -> gr.Blocks:
337
  return demo
338
 
339
 
340
- def _panel_header(index: str, title: str, chinese: str, note: str) -> str:
 
341
  return f"""
342
  <header class="panel-header">
343
  <span>{escape(index)}</span>
344
  <div>
345
- <h2>{escape(title)} <small>{escape(chinese)}</small></h2>
346
  <p>{escape(note)}</p>
347
  </div>
348
  </header>
@@ -422,7 +564,7 @@ def _render_object_file(result: GenerationResult) -> str:
422
  </div>
423
  </dl>
424
  <div class="feature-list">
425
- <strong>Visible features / 可见特征</strong>
426
  <ul>{features}</ul>
427
  </div>
428
  <p class="complaint">{escape(persona.complaint)}</p>
@@ -434,7 +576,7 @@ def _render_object_file(result: GenerationResult) -> str:
434
  def _render_trace_summary(result: GenerationResult) -> str:
435
  return f"""
436
  <div class="trace-card">
437
- <span class="archive-label">Trace saved / Trace 已保存</span>
438
  <strong>{escape(result.trace.trace_id)}</strong>
439
  <p>{escape(result.trace.model_runtime["vision"])} · {escape(result.trace.model_runtime["text"])}</p>
440
  </div>
@@ -451,15 +593,16 @@ def _generation_error(exc: Exception, description: str, mode: str) -> Generation
451
  }
452
  error_html = f"""
453
  <div class="archive-error">
454
- <span>Generation failed / 生成失败</span>
455
  <strong>{escape(error_type)}</strong>
456
  <p>{escape(error_message)}</p>
457
  </div>
458
  """
459
  error_markdown = (
460
- "### Generation failed / 生成失败\n\n"
461
  f"{error_type}: {error_message}\n\n"
462
- "Please try another description or sample object. / 请尝试其他描述或示例物品。"
 
463
  )
464
  return (
465
  error_html,
@@ -471,7 +614,7 @@ def _generation_error(exc: Exception, description: str, mode: str) -> Generation
471
  error_payload,
472
  "",
473
  None,
474
- [{"role": "assistant", "content": f"Generation failed. / 生成失败:{error_type}"}],
475
  )
476
 
477
 
@@ -484,7 +627,7 @@ def _awake_chat_history(result: GenerationResult) -> list[dict[str, str]]:
484
  return [
485
  {
486
  "role": "assistant",
487
- "content": f"{name} is awake. Ask what it remembers. / {name} 已被唤醒,可以追问它记得什么。",
488
  }
489
  ]
490
 
 
19
  from src.ui import copy
20
  from src.utils.zero_gpu import zero_gpu
21
 
22
+ CHAT_EMPTY_MESSAGE = "Wake an object first."
23
 
24
  OBJECT_FILE_EMPTY = """
25
  <div class="archive-empty">
26
+ <span class="archive-label">Object File <span class="lang-zh">物品档案</span></span>
27
  <h3>No object awake yet.</h3>
28
+ <p>Upload or describe an everyday object to open its secret archive.</p>
29
+ <p class="lang-zh block">上传或描述一个日常物品后打开秘密档案。</p>
30
  </div>
31
  """
32
 
33
  DIARY_EMPTY = """
34
+ ### Secret Diary
35
 
36
+ Wake an object to open its diary.
37
+
38
+ <div class="lang-zh block zh-helper">
39
+ 唤醒物品后阅读它的日记。
40
+ </div>
41
  """
42
 
43
  SHARE_CARD_EMPTY = """
44
  <div class="objectverse-placeholder">
45
+ <span>Share Card <span class="lang-zh">分享卡片</span></span>
46
  <strong>Waiting for an object file.</strong>
47
+ <p>A screenshot-friendly archive card will appear here.</p>
48
+ <p class="lang-zh block">可截图分享的档案卡片会显示在这里。</p>
49
  </div>
50
  """
51
 
52
  TRACE_EMPTY = """
53
  <div class="archive-empty compact">
54
+ <span class="archive-label">Trace <span class="lang-zh">模型轨迹</span></span>
55
+ <p>No trace saved yet.</p>
56
+ <p class="lang-zh block">尚未保存 trace。</p>
57
  </div>
58
  """
59
 
60
+ UI_CONTROL_SCRIPT = r"""
61
+ (() => {
62
+ const root = document.documentElement;
63
+ const INTERNAL_TEXT_REPLACEMENTS = new Map([
64
+ ["将图像文件拖放到此处以上传", "Drop image file here to upload"],
65
+ ["将图像拖放到此处", "Drop image here"],
66
+ ["- 或 -", "- or -"],
67
+ ["点击上传", "Click to upload"],
68
+ ["清空对话", "Clear chat"],
69
+ ["通过 API 使用", "Use via API"],
70
+ ["使用 Gradio 构建", "Built with Gradio"],
71
+ ["设置", "Settings"],
72
+ ["标志", "icon"],
73
+ ]);
74
+ const CJK_RE = /[\u3400-\u9fff]/;
75
+ const CJK_WRAP_RE = /[\u3400-\u9fff,。!?、;::“”‘’()《》【】]+/g;
76
+ const SKIP_TEXT_SELECTOR = "script, style, textarea, input, select, option, svg, .lang-zh, .auto-zh";
77
+
78
+ function syncLanguageButtons(value) {
79
+ document.querySelectorAll("[data-lang-toggle]").forEach((button) => {
80
+ const active = button.dataset.langToggle === value;
81
+ button.classList.toggle("active", active);
82
+ button.setAttribute("aria-pressed", String(active));
83
+ });
84
+ }
85
+
86
+ function applyLanguage(value) {
87
+ const language = value === "zh" ? "zh" : "en";
88
+ root.dataset.ovLang = language;
89
+ if (document.body) {
90
+ document.body.dataset.ovLang = language;
91
+ }
92
+ syncLanguageButtons(language);
93
+ }
94
+
95
+ function initControls() {
96
+ root.lang = "en";
97
+ applyLanguage("en");
98
+ normalizeGradioChrome(document.body);
99
+ wrapChineseText(document.body);
100
+ }
101
+
102
+ function normalizeString(value) {
103
+ let nextValue = value;
104
+ INTERNAL_TEXT_REPLACEMENTS.forEach((replacement, source) => {
105
+ nextValue = nextValue.split(source).join(replacement);
106
+ });
107
+ return nextValue;
108
+ }
109
+
110
+ function normalizeGradioChrome(rootNode) {
111
+ if (!rootNode) return;
112
+
113
+ rootNode.querySelectorAll("[aria-label], [title], [alt]").forEach((element) => {
114
+ ["aria-label", "title", "alt"].forEach((attribute) => {
115
+ const value = element.getAttribute(attribute);
116
+ if (value && CJK_RE.test(value)) {
117
+ const normalizedValue = normalizeString(value);
118
+ if (normalizedValue !== value) {
119
+ element.setAttribute(attribute, normalizedValue);
120
+ }
121
+ }
122
+ });
123
+ });
124
+
125
+ const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT);
126
+ const nodes = [];
127
+ let node = walker.nextNode();
128
+ while (node) {
129
+ const parent = node.parentElement;
130
+ const text = node.nodeValue || "";
131
+ if (parent && !parent.closest(SKIP_TEXT_SELECTOR) && CJK_RE.test(text)) {
132
+ nodes.push(node);
133
+ }
134
+ node = walker.nextNode();
135
+ }
136
+
137
+ nodes.forEach((textNode) => {
138
+ const text = textNode.nodeValue || "";
139
+ const normalizedText = normalizeString(text);
140
+ if (normalizedText !== text) {
141
+ textNode.nodeValue = normalizedText;
142
+ }
143
+ });
144
+ }
145
+
146
+ function wrapChineseText(rootNode) {
147
+ if (!rootNode) return;
148
+
149
+ const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT);
150
+ const nodes = [];
151
+ let node = walker.nextNode();
152
+ while (node) {
153
+ const parent = node.parentElement;
154
+ const text = node.nodeValue || "";
155
+ if (parent && !parent.closest(SKIP_TEXT_SELECTOR) && CJK_RE.test(text)) {
156
+ nodes.push(node);
157
+ }
158
+ node = walker.nextNode();
159
+ }
160
+
161
+ nodes.forEach((textNode) => {
162
+ const text = textNode.nodeValue || "";
163
+ const fragment = document.createDocumentFragment();
164
+ let lastIndex = 0;
165
+ text.replace(CJK_WRAP_RE, (match, index) => {
166
+ if (index > lastIndex) {
167
+ fragment.append(document.createTextNode(text.slice(lastIndex, index)));
168
+ }
169
+ const span = document.createElement("span");
170
+ span.className = "auto-zh";
171
+ span.textContent = match;
172
+ fragment.append(span);
173
+ lastIndex = index + match.length;
174
+ return match;
175
+ });
176
+ if (lastIndex < text.length) {
177
+ fragment.append(document.createTextNode(text.slice(lastIndex)));
178
+ }
179
+ textNode.replaceWith(fragment);
180
+ });
181
+ }
182
+
183
+ document.addEventListener("click", (event) => {
184
+ const langButton = event.target.closest("[data-lang-toggle]");
185
+ if (langButton) {
186
+ applyLanguage(langButton.dataset.langToggle);
187
+ }
188
+ });
189
+
190
+ if (document.readyState === "loading") {
191
+ document.addEventListener("DOMContentLoaded", initControls);
192
+ } else {
193
+ initControls();
194
+ }
195
+
196
+ const observer = new MutationObserver(() => {
197
+ normalizeGradioChrome(document.body);
198
+ wrapChineseText(document.body);
199
+ });
200
+ if (document.body) {
201
+ observer.observe(document.body, { childList: true, subtree: true });
202
+ } else {
203
+ document.addEventListener("DOMContentLoaded", () => {
204
+ observer.observe(document.body, { childList: true, subtree: true });
205
+ });
206
+ }
207
+ })();
208
+ """
209
+
210
  GenerationUiResult = tuple[
211
  str,
212
  dict[str, Any],
 
237
  background_fill_secondary_dark="rgba(30, 28, 25, 0.6)",
238
  border_color_primary="rgba(212, 175, 55, 0.15)",
239
  border_color_primary_dark="rgba(212, 175, 55, 0.15)",
240
+ body_text_color="#E6E1D3",
241
+ body_text_color_dark="#E6E1D3",
242
+ body_text_color_subdued="#A89B84",
243
+ body_text_color_subdued_dark="#A89B84",
244
+ link_text_color="#D4AF37",
245
+ link_text_color_dark="#D4AF37",
246
+ link_text_color_hover="#F5D061",
247
+ link_text_color_hover_dark="#F5D061",
248
+ link_text_color_active="#F5D061",
249
+ link_text_color_active_dark="#F5D061",
250
+ link_text_color_visited="#D4AF37",
251
+ link_text_color_visited_dark="#D4AF37",
252
  block_background_fill="transparent",
253
  block_background_fill_dark="transparent",
254
  block_border_width="0px",
255
+ block_info_text_color="#A89B84",
256
+ block_info_text_color_dark="#A89B84",
257
+ block_label_text_color="#A89B84",
258
+ block_label_text_color_dark="#A89B84",
259
+ block_title_text_color="#E6E1D3",
260
+ block_title_text_color_dark="#E6E1D3",
261
  panel_background_fill="transparent",
262
  panel_background_fill_dark="transparent",
263
+ accordion_text_color="#E6E1D3",
264
+ accordion_text_color_dark="#E6E1D3",
265
+ table_text_color="#E6E1D3",
266
+ table_text_color_dark="#E6E1D3",
267
+ input_background_fill="#1b1a18",
268
+ input_background_fill_dark="#1b1a18",
269
+ input_background_fill_focus="#1b1a18",
270
+ input_background_fill_focus_dark="#1b1a18",
271
+ input_background_fill_hover="#1b1a18",
272
+ input_background_fill_hover_dark="#1b1a18",
273
+ input_border_color="rgba(212, 175, 55, 0.3)",
274
+ input_border_color_dark="rgba(212, 175, 55, 0.3)",
275
+ input_border_color_focus="#D4AF37",
276
+ input_border_color_focus_dark="#D4AF37",
277
+ input_placeholder_color="#8B8678",
278
+ input_placeholder_color_dark="#8B8678",
279
+ checkbox_label_text_color="#E6E1D3",
280
+ checkbox_label_text_color_dark="#E6E1D3",
281
+ checkbox_label_text_color_selected="#F5D061",
282
+ checkbox_label_text_color_selected_dark="#F5D061",
283
+ checkbox_label_background_fill="transparent",
284
+ checkbox_label_background_fill_dark="transparent",
285
+ checkbox_label_background_fill_selected="rgba(212, 175, 55, 0.05)",
286
+ checkbox_label_background_fill_selected_dark="rgba(212, 175, 55, 0.05)",
287
+ checkbox_label_border_color="rgba(212, 175, 55, 0.3)",
288
+ checkbox_label_border_color_dark="rgba(212, 175, 55, 0.3)",
289
+ checkbox_label_border_color_selected="#D4AF37",
290
+ checkbox_label_border_color_selected_dark="#D4AF37",
291
+ button_secondary_background_fill="rgba(22, 21, 19, 0.8)",
292
+ button_secondary_background_fill_dark="rgba(22, 21, 19, 0.8)",
293
+ button_secondary_background_fill_hover="rgba(38, 35, 29, 0.9)",
294
+ button_secondary_background_fill_hover_dark="rgba(38, 35, 29, 0.9)",
295
+ button_secondary_border_color="rgba(212, 175, 55, 0.15)",
296
+ button_secondary_border_color_dark="rgba(212, 175, 55, 0.15)",
297
+ button_secondary_border_color_hover="#D4AF37",
298
+ button_secondary_border_color_hover_dark="#D4AF37",
299
+ button_secondary_text_color="#E6E1D3",
300
+ button_secondary_text_color_dark="#E6E1D3",
301
+ button_secondary_text_color_hover="#F5D061",
302
+ button_secondary_text_color_hover_dark="#F5D061",
303
+ button_primary_text_color="#2a261f",
304
+ button_primary_text_color_dark="#2a261f",
305
  )
306
 
307
+ with gr.Blocks(theme=custom_theme, head=f"<style>{css}</style><script>{UI_CONTROL_SCRIPT}</script>", title=APP_TITLE, fill_width=True, elem_id="objectverse-app") as demo:
308
+ with gr.Column(elem_id="app-container"):
309
+ gr.HTML(
310
+ f"""
311
+ <header id="objectverse-hero">
312
+ <div class="hero-copy">
313
+ <span class="archive-label">Small Model Object Archive</span>
314
+ <h1>{APP_TITLE}</h1>
315
+ <p class="hero-kicker">Every object has a secret life.</p>
316
+ <p class="hero-feature">Build Small Hackathon entry: upload an object, wake its secret persona, read the diary, chat, and share the evidence. Tiny models, weird lives.</p>
317
+ <p class="hero-kicker lang-zh block">万物日记:每个物品都有秘密人生。</p>
318
+ <p class="hero-feature lang-zh block">Build Small 黑客松作品:上传物品,唤醒隐藏人格,读日记、追问它,再分享证据。小模型,怪人生。</p>
319
+ </div>
320
+ <div class="top-controls" aria-label="Display controls">
321
+ <span>Language</span>
322
+ <div class="segmented-control">
323
+ <button type="button" class="active" data-lang-toggle="en" aria-pressed="true">EN</button>
324
+ <button type="button" data-lang-toggle="zh" aria-pressed="false">ZH</button>
325
+ </div>
326
+ </div>
327
+ </header>
328
+ """,
329
+ padding=False,
330
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
+ result_state = gr.State()
333
+ zero_gpu_probe_button = gr.Button(visible=False)
334
+ zero_gpu_probe_output = gr.JSON(visible=False)
335
+ vision_runtime_probe_button = gr.Button(visible=False)
336
+ vision_runtime_probe_output = gr.JSON(visible=False)
337
+
338
+ with gr.Row(elem_id="intake", elem_classes=["content-section", "top-grid"]):
339
+ with gr.Column(scale=7, elem_classes=["archive-panel", "intake-panel"]):
340
+ gr.HTML(_panel_header("01", "Wake an Object", "Upload a photo or describe an everyday object.", "唤醒物品"), padding=False)
341
+ image_input = gr.Image(
342
+ label=copy.UPLOAD_LABEL,
343
+ show_label=False,
344
+ type="filepath",
345
+ sources=["upload"],
346
+ placeholder="Drop an object photo here or click to upload.",
347
+ elem_id="object-upload",
348
+ )
349
+ gr.HTML("""<div class="or-divider"><span>OR</span></div>""", padding=False)
350
+ description_input = gr.Textbox(
351
+ label=copy.DESCRIPTION_LABEL,
352
+ placeholder=copy.DESCRIPTION_PLACEHOLDER,
353
+ lines=2,
354
+ max_lines=5,
355
+ elem_id="object-description",
356
+ )
357
+
358
+ gr.HTML("""<div class="mode-header">Personality mode <span class="lang-zh">人格模式</span></div>""", padding=False)
359
+ mode_input = gr.Radio(
360
+ label=copy.MODE_LABEL,
361
+ show_label=False,
362
+ choices=PERSONALITY_MODES,
363
+ value=DEFAULT_MODE,
364
+ elem_id="personality-mode",
365
+ elem_classes=["mode-switch"],
366
+ )
367
+ generate_button = gr.Button("Wake the Object", variant="primary", elem_id="wake-button")
368
+
369
+ with gr.Column(scale=4, elem_classes=["archive-panel", "examples-panel"]):
370
+ gr.HTML(
371
+ """
372
+ <div class="example-header">
373
+ <div>
374
+ <strong>Example Objects</strong>
375
+ <span class="lang-zh block">示例物品</span>
376
+ </div>
377
+ </div>
378
+ """,
379
+ padding=False,
380
+ )
381
+ example_buttons: list[gr.Button] = []
382
+ for index in range(len(EXAMPLE_OBJECTS)):
383
+ example_buttons.append(
384
+ gr.Button(
385
+ example_button_label(index),
386
+ elem_classes=["example-card"],
387
+ variant="secondary",
388
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  )
 
 
390
 
391
+ with gr.Row(elem_id="results", elem_classes=["content-section", "results-grid"]):
392
+ with gr.Column(scale=5, elem_classes=["archive-panel", "file-panel"]):
393
+ gr.HTML(_panel_header("02", "Object File", "Structured understanding and persona.", "物品档案"), padding=False)
394
+ object_file_summary = gr.HTML(value=OBJECT_FILE_EMPTY, elem_id="object-file-summary", padding=False)
395
+
396
+ with gr.Column(scale=6, elem_classes=["archive-panel", "diary-panel"]):
397
+ gr.HTML(_panel_header("03", "Secret Diary", "A private note written by the object.", "秘密日记"), padding=False)
398
+ diary_output = gr.Markdown(
399
+ value=DIARY_EMPTY,
400
+ label=copy.DIARY_LABEL,
401
+ elem_id="diary-output",
402
+ )
403
+
404
+ with gr.Row(elem_id="share-chat", elem_classes=["content-section", "split-section"]):
405
+ with gr.Column(scale=5, elem_classes=["archive-panel", "share-panel"], elem_id="share-panel"):
406
+ gr.HTML(_panel_header("04", "Share Card", "Fixed-width card for screenshots.", "分享卡片"), padding=False)
407
+ share_card = gr.HTML(value=SHARE_CARD_EMPTY, label=copy.SHARE_CARD_LABEL, padding=False)
408
+
409
+ with gr.Column(scale=4, elem_classes=["archive-panel", "chat-panel"], elem_id="chat-panel"):
410
+ gr.HTML(_panel_header("05", "Object Chat", "Ask after the object wakes up.", "物品对话"), padding=False)
411
+ chatbot = gr.Chatbot(
412
+ value=_empty_chat_history(),
413
+ label=copy.CHAT_LABEL,
414
+ type="messages",
415
+ height=300,
416
+ allow_tags=False,
417
+ )
418
+ chat_input = gr.Textbox(placeholder=copy.CHAT_INPUT_PLACEHOLDER, show_label=False)
419
+ chat_button = gr.Button(copy.CHAT_BUTTON_LABEL, elem_classes=["quiet-button"])
420
+
421
+ with gr.Accordion("Developer details", open=False, elem_classes=["developer-details"]):
422
+ trace_summary = gr.HTML(value=TRACE_EMPTY, elem_id="trace-summary", padding=False)
423
+ with gr.Row(elem_classes=["developer-json-grid"]):
424
+ object_json = gr.JSON(value={}, label=copy.OBJECT_JSON_LABEL)
425
+ persona_json = gr.JSON(value={}, label=copy.PERSONA_JSON_LABEL)
426
+ trace_json = gr.JSON(value={}, label=copy.TRACE_JSON_LABEL)
427
+ trace_path = gr.Textbox(label=copy.TRACE_PATH_LABEL, interactive=False)
428
 
429
  manual_outputs = [
430
  object_file_summary,
 
478
  return demo
479
 
480
 
481
+ def _panel_header(index: str, title: str, note: str, chinese: str = "") -> str:
482
+ chinese_label = f' <small class="lang-zh">{escape(chinese)}</small>' if chinese else ""
483
  return f"""
484
  <header class="panel-header">
485
  <span>{escape(index)}</span>
486
  <div>
487
+ <h2>{escape(title)}{chinese_label}</h2>
488
  <p>{escape(note)}</p>
489
  </div>
490
  </header>
 
564
  </div>
565
  </dl>
566
  <div class="feature-list">
567
+ <strong>Visible features <span class="lang-zh">可见特征</span></strong>
568
  <ul>{features}</ul>
569
  </div>
570
  <p class="complaint">{escape(persona.complaint)}</p>
 
576
  def _render_trace_summary(result: GenerationResult) -> str:
577
  return f"""
578
  <div class="trace-card">
579
+ <span class="archive-label">Trace saved <span class="lang-zh">Trace 已保存</span></span>
580
  <strong>{escape(result.trace.trace_id)}</strong>
581
  <p>{escape(result.trace.model_runtime["vision"])} · {escape(result.trace.model_runtime["text"])}</p>
582
  </div>
 
593
  }
594
  error_html = f"""
595
  <div class="archive-error">
596
+ <span>Generation failed <span class="lang-zh">生成失败</span></span>
597
  <strong>{escape(error_type)}</strong>
598
  <p>{escape(error_message)}</p>
599
  </div>
600
  """
601
  error_markdown = (
602
+ "### Generation failed\n\n"
603
  f"{error_type}: {error_message}\n\n"
604
+ "Please try another description or sample object.\n\n"
605
+ '<div class="lang-zh block zh-helper">请尝试其他描述或示例物品。</div>'
606
  )
607
  return (
608
  error_html,
 
614
  error_payload,
615
  "",
616
  None,
617
+ [{"role": "assistant", "content": f"Generation failed: {error_type}"}],
618
  )
619
 
620
 
 
627
  return [
628
  {
629
  "role": "assistant",
630
+ "content": f"{name} is awake. Ask what it remembers.",
631
  }
632
  ]
633
 
src/ui/styles.css CHANGED
@@ -1,618 +1,649 @@
1
- /*
2
- * Objectverse Diary - Dark Academia / Vintage Archive Theme
3
- * Updated to match reference UI.
4
  */
5
 
6
- @import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap');
7
-
8
- :root {
9
- --ov-bg: #161513;
10
- --ov-bg-panel: rgba(30, 28, 25, 0.6);
11
- --ov-bg-input: #1b1a18;
12
-
13
- --ov-border-faint: rgba(212, 175, 55, 0.15);
14
- --ov-border-light: rgba(212, 175, 55, 0.3);
15
- --ov-border-strong: rgba(212, 175, 55, 0.8);
16
-
17
- --ov-text-main: #E6E1D3;
18
- --ov-text-muted: #8B8678;
19
- --ov-text-dark: #2a261f;
20
-
21
- --ov-gold: #D4AF37;
22
- --ov-gold-bright: #F5D061;
23
-
24
- --font-typewriter: 'Courier Prime', 'Space Mono', 'Courier New', monospace;
25
- --font-sans: 'Inter', -apple-system, sans-serif;
26
- --font-serif: Georgia, serif;
27
- }
28
-
29
- html, body, gradio-app {
30
- background-color: var(--ov-bg);
31
- margin: 0;
32
- padding: 0;
33
- width: 100%;
34
- height: 100%;
35
- color: var(--ov-text-main);
36
- }
37
-
38
- /* Subtle noise overlay */
39
- body::before {
40
- content: "";
41
- position: fixed;
42
- top: 0; left: 0; right: 0; bottom: 0;
43
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.03'/%3E%3C/svg%3E");
44
- pointer-events: none;
45
- z-index: 9999;
46
- }
47
-
48
- .gradio-container {
49
- max-width: 100% !important;
50
- padding: 0 !important;
51
- background: transparent !important;
52
- font-family: var(--font-sans);
53
- }
54
-
55
- /* Layout wrapper */
56
- #app-container {
57
- display: flex;
58
- flex-direction: row;
59
- min-height: 100vh;
60
- align-items: stretch;
61
- gap: 0 !important;
62
- margin: 0 !important;
63
- }
64
-
65
- /* ====================
66
- Sidebar Styles
67
- ==================== */
68
- #sidebar {
69
- width: 240px;
70
- min-width: 240px !important;
71
- max-width: 240px !important;
72
- border-right: 1px solid var(--ov-border-faint);
73
- background: rgba(22, 21, 19, 0.95);
74
- position: fixed;
75
- top: 0;
76
- bottom: 0;
77
- left: 0;
78
- display: flex;
79
- flex-direction: column;
80
- z-index: 100;
81
- padding: 30px 0;
82
- }
83
-
84
- .sidebar-logo {
85
- text-align: center;
86
- margin-bottom: 40px;
87
- }
88
-
89
- .sidebar-logo h2 {
90
- font-family: var(--font-typewriter);
91
- font-size: 18px;
92
- color: var(--ov-text-main);
93
- margin: 10px 0 0;
94
- line-height: 1.2;
95
- font-weight: normal;
96
- }
97
-
98
- .logo-icon {
99
- width: 48px;
100
- height: 64px;
101
- margin: 0 auto;
102
- border: 1px solid var(--ov-gold);
103
- border-radius: 24px;
104
- display: flex;
105
- align-items: center;
106
- justify-content: center;
107
- position: relative;
108
- }
109
-
110
- .logo-icon::after {
111
- content: "⚷"; /* Key symbol placeholder */
112
- color: var(--ov-gold);
113
- font-size: 24px;
114
- }
115
-
116
- .sidebar-menu {
117
- list-style: none;
118
- padding: 0;
119
- margin: 0;
120
- flex-grow: 1;
121
- }
122
-
123
- .sidebar-menu li {
124
- margin-bottom: 5px;
125
- }
126
-
127
- .sidebar-menu a {
128
- display: flex;
129
- align-items: center;
130
- padding: 12px 30px;
131
- color: var(--ov-text-muted);
132
- text-decoration: none;
133
- font-size: 15px;
134
- font-family: var(--font-typewriter);
135
- border-left: 3px solid transparent;
136
- transition: all 0.2s;
137
- }
138
-
139
- .sidebar-menu li.active a,
140
- .sidebar-menu a:hover {
141
- color: var(--ov-gold);
142
- background: linear-gradient(90deg, rgba(212, 175, 55, 0.1) 0%, transparent 100%);
143
- border-left-color: var(--ov-gold);
144
- }
145
-
146
- .sidebar-footer {
147
- padding: 0 20px;
148
- }
149
-
150
- .footer-stamp {
151
- border: 1px solid var(--ov-border-faint);
152
- padding: 15px;
153
- text-align: center;
154
- border-radius: 4px;
155
- margin-bottom: 20px;
156
- }
157
-
158
- .footer-stamp small {
159
- display: block;
160
- font-size: 9px;
161
- color: var(--ov-text-muted);
162
- text-transform: uppercase;
163
- letter-spacing: 1px;
164
- }
165
-
166
- .footer-stamp span {
167
- display: block;
168
- font-family: var(--font-typewriter);
169
- color: var(--ov-gold);
170
- font-size: 13px;
171
- margin: 5px 0;
172
- }
173
-
174
- .lang-switch {
175
- display: flex;
176
- border: 1px solid var(--ov-border-light);
177
- border-radius: 4px;
178
- overflow: hidden;
179
- }
180
-
181
- .lang-switch button {
182
- flex: 1;
183
- background: transparent;
184
- border: none;
185
- color: var(--ov-text-muted);
186
- padding: 8px 0;
187
- font-size: 12px;
188
- cursor: pointer;
189
- }
190
-
191
- .lang-switch button.active {
192
- color: var(--ov-gold);
193
- background: rgba(212, 175, 55, 0.05);
194
- }
195
-
196
- /* ====================
197
- Main Content Area
198
- ==================== */
199
- #main-content {
200
- margin-left: 240px;
201
- padding: 40px 60px;
202
- max-width: 1200px;
203
- }
204
-
205
- #objectverse-hero {
206
- margin-bottom: 40px;
207
- position: relative;
208
- }
209
-
210
- #objectverse-hero h1 {
211
- font-family: var(--font-typewriter);
212
- font-size: 42px;
213
- color: var(--ov-text-main);
214
- margin: 0 0 10px 0;
215
- letter-spacing: -0.5px;
216
- }
217
-
218
- .hero-kicker {
219
- font-size: 18px;
220
- color: var(--ov-gold);
221
- font-style: italic;
222
- font-family: var(--font-serif);
223
- margin: 0;
224
- }
225
-
226
- .hero-kicker span {
227
- font-size: 14px;
228
- font-style: normal;
229
- color: #A89B84 !important;
230
- font-family: var(--font-sans);
231
- }
232
-
233
- .hero-badges {
234
- display: flex;
235
- gap: 15px;
236
- margin-top: 25px;
237
- }
238
-
239
- .hero-badges span {
240
- border: 1px solid var(--ov-border-light);
241
- padding: 6px 16px;
242
- border-radius: 20px;
243
- font-size: 13px;
244
- color: var(--ov-text-muted);
245
- font-family: var(--font-typewriter);
246
- display: flex;
247
- align-items: center;
248
- gap: 6px;
249
- }
250
-
251
- .content-section {
252
- margin-bottom: 30px;
253
- gap: 30px !important;
254
- }
255
-
256
- .archive-panel {
257
- background: var(--ov-bg-panel) !important;
258
- border: 1px solid var(--ov-border-faint) !important;
259
- border-radius: 8px;
260
- padding: 25px;
261
- position: relative;
262
- }
263
-
264
- /* Gradio Overrides */
265
- .gradio-container .block,
266
- .gradio-container .form,
267
- .gradio-container .box {
268
- background: transparent !important;
269
- border: none !important;
270
- box-shadow: none !important;
271
- }
272
-
273
- .gradio-container label, .gradio-container span.svelte-1gfknul {
274
- color: var(--ov-text-muted) !important;
275
- font-family: var(--font-typewriter);
276
- }
277
-
278
- .gradio-container input, .gradio-container textarea {
279
- background: var(--ov-bg-input) !important;
280
- border: 1px solid var(--ov-border-light) !important;
281
- border-radius: 4px !important;
282
- color: var(--ov-text-main) !important;
283
- font-family: var(--font-sans) !important;
284
- }
285
-
286
- .gradio-container input:focus, .gradio-container textarea:focus {
287
- border-color: var(--ov-gold) !important;
288
- box-shadow: none !important;
289
- }
290
-
291
- /* Upload Box */
292
- #object-upload {
293
- border: 2px dashed var(--ov-border-light) !important;
294
- background: transparent !important;
295
- border-radius: 8px;
296
- padding: 40px 20px;
297
- text-align: center;
298
- min-height: 180px;
299
- display: flex;
300
- align-items: center;
301
- justify-content: center;
302
- }
303
-
304
- .or-divider {
305
- text-align: center;
306
- position: relative;
307
- margin: 20px 0;
308
- }
309
-
310
- .or-divider::before {
311
- content: "";
312
- position: absolute;
313
- left: 0; right: 0; top: 50%;
314
- height: 1px;
315
- background: var(--ov-border-faint);
316
- z-index: 1;
317
- }
318
-
319
- .or-divider span {
320
- background: var(--ov-bg-panel);
321
- padding: 0 15px;
322
- position: relative;
323
- z-index: 2;
324
- color: var(--ov-text-muted);
325
- font-family: var(--font-typewriter);
326
- font-size: 14px;
327
- }
328
-
329
- /* Personality Mode Radio */
330
- .mode-header {
331
- font-family: var(--font-typewriter);
332
- color: var(--ov-text-main);
333
- margin-bottom: 15px;
334
- display: flex;
335
- align-items: center;
336
- gap: 10px;
337
- }
338
-
339
- .mode-header small {
340
- color: var(--ov-text-muted);
341
- font-family: var(--font-sans);
342
- }
343
-
344
- #personality-mode .wrap {
345
- display: flex !important;
346
- gap: 10px !important;
347
- flex-wrap: wrap !important;
348
- }
349
-
350
- #personality-mode label {
351
- flex: 1;
352
- background: transparent !important;
353
- border: 1px solid var(--ov-border-light) !important;
354
- border-radius: 6px !important;
355
- padding: 15px 10px !important;
356
- text-align: center;
357
- cursor: pointer;
358
- transition: all 0.2s;
359
- }
360
-
361
- #personality-mode label span {
362
- display: block;
363
- font-family: var(--font-typewriter);
364
- color: var(--ov-text-main) !important;
365
- font-size: 14px;
366
- }
367
-
368
- #personality-mode label:has(input:checked) {
369
- border-color: var(--ov-gold) !important;
370
- background: rgba(212, 175, 55, 0.05) !important;
371
- box-shadow: 0 0 0 1px var(--ov-gold) inset;
372
- }
373
-
374
- #personality-mode label:has(input:checked) span {
375
- color: var(--ov-gold-bright) !important;
376
- }
377
-
378
- /* Wake Button */
379
- #wake-button {
380
- background: linear-gradient(180deg, #d8ac54 0%, #a67c2d 100%) !important;
381
- border: none !important;
382
- border-radius: 4px !important;
383
- color: var(--ov-text-dark) !important;
384
- font-family: var(--font-typewriter);
385
- font-size: 20px !important;
386
- font-weight: bold;
387
- padding: 20px !important;
388
- margin-top: 25px;
389
- box-shadow: inset 0 1px 1px rgba(255,255,255,0.3), 0 4px 15px rgba(0,0,0,0.5) !important;
390
- text-shadow: 0 1px 0 rgba(255,255,255,0.2);
391
- transition: all 0.2s;
392
- }
393
-
394
- #wake-button:hover {
395
- filter: brightness(1.1);
396
- transform: translateY(-1px);
397
- }
398
-
399
- /* How it works */
400
- .how-it-works {
401
- display: flex;
402
- gap: 20px;
403
- margin-top: 40px;
404
- padding-top: 30px;
405
- border-top: 1px dashed var(--ov-border-faint);
406
- }
407
-
408
- .step {
409
- flex: 1;
410
- position: relative;
411
- }
412
-
413
- .step-num {
414
- position: absolute;
415
- top: -10px; left: -10px;
416
- background: var(--ov-bg);
417
- border: 1px solid var(--ov-border-light);
418
- color: var(--ov-gold);
419
- font-family: var(--font-typewriter);
420
- font-size: 12px;
421
- padding: 2px 8px;
422
- }
423
-
424
- .step-text strong {
425
- display: block;
426
- color: var(--ov-text-main);
427
- font-family: var(--font-typewriter);
428
- font-size: 14px;
429
- margin-top: 15px;
430
- }
431
-
432
- .step-text small {
433
- display: block;
434
- color: var(--ov-text-muted);
435
- font-size: 12px;
436
- margin-bottom: 8px;
437
- }
438
-
439
- .step-text p {
440
- color: var(--ov-text-muted);
441
- font-size: 13px;
442
- line-height: 1.4;
443
- margin: 0;
444
- }
445
-
446
- /* Example Objects Panel */
447
- .example-header {
448
- display: flex;
449
- align-items: center;
450
- gap: 15px;
451
- margin-bottom: 20px;
452
- border-bottom: 1px solid var(--ov-border-faint);
453
- padding-bottom: 15px;
454
- }
455
-
456
- .example-header strong {
457
- display: block;
458
- font-family: var(--font-typewriter);
459
- font-size: 16px;
460
- font-weight: normal;
461
- }
462
-
463
- .example-header span {
464
- color: var(--ov-text-muted);
465
- font-size: 13px;
466
- }
467
-
468
- button.example-card {
469
- background: rgba(22, 21, 19, 0.8) !important;
470
- border: 1px solid var(--ov-border-faint) !important;
471
- border-radius: 4px !important;
472
- color: var(--ov-text-main) !important;
473
- text-align: left !important;
474
- padding: 15px !important;
475
- margin-bottom: 12px !important;
476
- font-family: var(--font-typewriter) !important;
477
- display: block;
478
- width: 100%;
479
- transition: border-color 0.2s;
480
- }
481
-
482
- button.example-card:hover {
483
- border-color: var(--ov-gold) !important;
484
- }
485
-
486
- .view-more {
487
- display: block;
488
- text-align: right;
489
- color: var(--ov-gold);
490
- text-decoration: none;
491
- font-family: var(--font-typewriter);
492
- font-size: 14px;
493
- margin-top: 15px;
494
- }
495
-
496
- /* Other Panels Formatting */
497
- .panel-header h2 {
498
- font-family: var(--font-typewriter);
499
- font-size: 24px;
500
- color: var(--ov-text-main);
501
- margin: 0 0 5px 0;
502
- }
503
-
504
- .panel-header {
505
- border-bottom: 1px solid var(--ov-border-faint);
506
- padding-bottom: 15px;
507
- margin-bottom: 20px;
508
- }
509
-
510
- .panel-header > span {
511
- background: transparent;
512
- border: none;
513
- color: var(--ov-gold) !important;
514
- font-family: var(--font-typewriter);
515
- font-size: 18px;
516
- padding: 0;
517
- }
518
-
519
- /* Markdown & Typography */
520
- #diary-output {
521
- font-family: var(--font-serif) !important;
522
- font-size: 18px;
523
- line-height: 1.8;
524
- color: #D6D1C4 !important;
525
- }
526
-
527
- #diary-output h3 {
528
- font-family: var(--font-typewriter);
529
- color: var(--ov-gold);
530
- text-transform: uppercase;
531
- font-size: 16px;
532
- }
533
-
534
- .archive-empty {
535
- text-align: center;
536
- padding: 40px;
537
- border: 1px dashed var(--ov-border-light);
538
- }
539
-
540
- .archive-empty h3 {
541
- font-family: var(--font-typewriter);
542
- }
543
-
544
- /* Responsive */
545
- @media (max-width: 980px) {
546
- #app-container {
547
- flex-direction: column;
548
- }
549
- #sidebar {
550
- position: static;
551
- width: 100% !important;
552
- max-width: 100% !important;
553
- height: auto;
554
- padding: 20px;
555
- border-right: none;
556
- border-bottom: 1px solid var(--ov-border-faint);
557
- }
558
- #main-content {
559
- margin-left: 0;
560
- padding: 20px;
561
- }
562
- .content-section {
563
- flex-direction: column !important;
564
- }
565
- .split-section {
566
- flex-direction: column !important;
567
- }
568
- }
569
-
570
- @media (max-width: 600px) {
571
- #main-content {
572
- padding: 15px !important;
573
- }
574
- #objectverse-hero h1 {
575
- font-size: 28px !important;
576
- word-break: break-word;
577
- }
578
- .hero-kicker {
579
- font-size: 15px !important;
580
- }
581
- #personality-mode label {
582
- flex: 1 1 45% !important;
583
- padding: 10px 5px !important;
584
- }
585
- .sidebar-menu {
586
- display: flex;
587
- flex-wrap: wrap;
588
- gap: 5px;
589
- }
590
- .sidebar-menu li {
591
- margin-bottom: 0;
592
- }
593
- .sidebar-menu a {
594
- padding: 8px 10px;
595
- font-size: 13px;
596
- border-left: none;
597
- border-bottom: 2px solid transparent;
598
- }
599
- .sidebar-menu li.active a {
600
- border-bottom-color: var(--ov-gold);
601
- border-left: none;
602
- background: rgba(212, 175, 55, 0.1);
603
- }
604
- .lang-switch {
605
- margin-top: 10px;
606
- }
607
- .how-it-works {
608
- flex-direction: column;
609
- gap: 20px;
610
- }
611
- .hero-badges {
612
- flex-wrap: wrap;
613
- }
614
- .hero-badges span {
615
- flex: 1 1 100%;
616
- justify-content: center;
617
- }
618
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Objectverse Diary - compact archive UI.
 
3
  */
4
 
5
+ @import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap');
6
+
7
+ :root {
8
+ --ov-bg: #161513;
9
+ --ov-bg-panel: rgba(30, 28, 25, 0.72);
10
+ --ov-bg-input: #1b1a18;
11
+ --ov-border-faint: rgba(212, 175, 55, 0.15);
12
+ --ov-border-light: rgba(212, 175, 55, 0.34);
13
+ --ov-text-main: #e6e1d3;
14
+ --ov-text-soft: #d6d1c4;
15
+ --ov-text-muted: #8b8678;
16
+ --ov-text-dark: #2a261f;
17
+ --ov-gold: #d4af37;
18
+ --ov-gold-bright: #f5d061;
19
+ --font-typewriter: 'Courier Prime', 'Space Mono', 'Courier New', monospace;
20
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
21
+ --font-serif: Georgia, serif;
22
+ }
23
+
24
+ .lang-zh,
25
+ .auto-zh {
26
+ display: none !important;
27
+ }
28
+
29
+ html[data-ov-lang="zh"] .lang-zh,
30
+ html[data-ov-lang="zh"] .auto-zh {
31
+ display: inline !important;
32
+ }
33
+
34
+ html[data-ov-lang="zh"] .lang-zh.block {
35
+ display: block !important;
36
+ }
37
+
38
+ html,
39
+ body,
40
+ gradio-app {
41
+ background: var(--ov-bg);
42
+ color: var(--ov-text-main);
43
+ min-height: 100%;
44
+ margin: 0;
45
+ overflow-x: hidden;
46
+ }
47
+
48
+ body::before {
49
+ content: "";
50
+ position: fixed;
51
+ inset: 0;
52
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.03'/%3E%3C/svg%3E");
53
+ pointer-events: none;
54
+ z-index: 9999;
55
+ }
56
+
57
+ .gradio-container {
58
+ width: 100% !important;
59
+ max-width: 100% !important;
60
+ min-height: 100vh !important;
61
+ padding: 0 !important;
62
+ background: transparent !important;
63
+ color: var(--ov-text-main) !important;
64
+ font-family: var(--font-sans);
65
+ }
66
+
67
+ footer,
68
+ .footer {
69
+ display: none !important;
70
+ }
71
+
72
+ #objectverse-app,
73
+ #app-container,
74
+ #main-content,
75
+ .archive-panel {
76
+ color: var(--ov-text-main);
77
+ }
78
+
79
+ #app-container {
80
+ max-width: 1180px;
81
+ margin: 0 auto !important;
82
+ padding: 36px 24px 56px;
83
+ gap: 24px !important;
84
+ }
85
+
86
+ #objectverse-hero {
87
+ display: flex;
88
+ justify-content: space-between;
89
+ align-items: flex-start;
90
+ gap: 24px;
91
+ padding-bottom: 22px;
92
+ border-bottom: 1px solid var(--ov-border-faint);
93
+ }
94
+
95
+ .hero-copy h1 {
96
+ margin: 6px 0 8px;
97
+ color: var(--ov-text-main) !important;
98
+ font-family: var(--font-typewriter);
99
+ font-size: clamp(32px, 5vw, 48px);
100
+ line-height: 1.05;
101
+ letter-spacing: 0;
102
+ }
103
+
104
+ .hero-kicker {
105
+ margin: 0;
106
+ color: var(--ov-gold) !important;
107
+ font-family: var(--font-serif);
108
+ font-size: 18px;
109
+ font-style: italic;
110
+ }
111
+
112
+ .hero-feature {
113
+ max-width: 680px;
114
+ margin: 12px 0 0;
115
+ color: var(--ov-text-soft) !important;
116
+ font-size: 15px;
117
+ line-height: 1.65;
118
+ }
119
+
120
+ .archive-label {
121
+ color: var(--ov-gold) !important;
122
+ font-family: var(--font-typewriter);
123
+ font-size: 12px;
124
+ letter-spacing: 0;
125
+ text-transform: uppercase;
126
+ }
127
+
128
+ .top-controls {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 12px;
132
+ min-width: 176px;
133
+ color: var(--ov-text-muted);
134
+ font-family: var(--font-typewriter);
135
+ font-size: 12px;
136
+ }
137
+
138
+ .segmented-control {
139
+ display: grid;
140
+ grid-template-columns: 1fr 1fr;
141
+ min-width: 92px;
142
+ border: 1px solid var(--ov-border-light);
143
+ border-radius: 4px;
144
+ overflow: hidden;
145
+ }
146
+
147
+ .segmented-control button {
148
+ min-height: 34px;
149
+ padding: 0 12px;
150
+ background: transparent;
151
+ border: 0;
152
+ border-right: 1px solid var(--ov-border-faint);
153
+ color: var(--ov-text-muted);
154
+ font-family: var(--font-typewriter);
155
+ font-size: 12px;
156
+ cursor: pointer;
157
+ }
158
+
159
+ .segmented-control button:last-child {
160
+ border-right: 0;
161
+ }
162
+
163
+ .segmented-control button:hover,
164
+ .segmented-control button:focus,
165
+ .segmented-control button.active {
166
+ color: var(--ov-gold);
167
+ background: rgba(212, 175, 55, 0.08);
168
+ outline: none;
169
+ }
170
+
171
+ .content-section {
172
+ display: flex !important;
173
+ gap: 24px !important;
174
+ margin: 0 0 2px;
175
+ }
176
+
177
+ .archive-panel {
178
+ position: relative;
179
+ padding: 22px;
180
+ background: var(--ov-bg-panel) !important;
181
+ border: 1px solid var(--ov-border-faint) !important;
182
+ border-radius: 8px;
183
+ }
184
+
185
+ .gradio-container .block,
186
+ .gradio-container .form,
187
+ .gradio-container .box {
188
+ background: transparent !important;
189
+ border: none !important;
190
+ box-shadow: none !important;
191
+ }
192
+
193
+ .gradio-container label,
194
+ .gradio-container span.svelte-1gfknul {
195
+ color: var(--ov-text-muted) !important;
196
+ font-family: var(--font-typewriter);
197
+ }
198
+
199
+ .gradio-container input,
200
+ .gradio-container textarea {
201
+ background: var(--ov-bg-input) !important;
202
+ border: 1px solid var(--ov-border-light) !important;
203
+ border-radius: 4px !important;
204
+ color: var(--ov-text-main) !important;
205
+ font-family: var(--font-sans) !important;
206
+ }
207
+
208
+ .gradio-container input:focus,
209
+ .gradio-container textarea:focus {
210
+ border-color: var(--ov-gold) !important;
211
+ box-shadow: none !important;
212
+ }
213
+
214
+ #object-upload {
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ min-height: 176px;
219
+ padding: 34px 20px;
220
+ border: 2px dashed var(--ov-border-light) !important;
221
+ border-radius: 8px;
222
+ background: transparent !important;
223
+ color: var(--ov-text-muted) !important;
224
+ text-align: center;
225
+ }
226
+
227
+ #object-upload :is(label, span, p, div, button) {
228
+ color: var(--ov-text-muted) !important;
229
+ }
230
+
231
+ .or-divider {
232
+ position: relative;
233
+ margin: 18px 0;
234
+ text-align: center;
235
+ }
236
+
237
+ .or-divider::before {
238
+ content: "";
239
+ position: absolute;
240
+ top: 50%;
241
+ right: 0;
242
+ left: 0;
243
+ height: 1px;
244
+ background: var(--ov-border-faint);
245
+ }
246
+
247
+ .or-divider span {
248
+ position: relative;
249
+ z-index: 1;
250
+ padding: 0 14px;
251
+ background: var(--ov-bg-panel);
252
+ color: var(--ov-text-muted);
253
+ font-family: var(--font-typewriter);
254
+ font-size: 13px;
255
+ }
256
+
257
+ .mode-header {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 8px;
261
+ margin: 18px 0 12px;
262
+ color: var(--ov-text-main) !important;
263
+ font-family: var(--font-typewriter);
264
+ }
265
+
266
+ .mode-header .lang-zh {
267
+ color: var(--ov-text-muted);
268
+ font-family: var(--font-sans);
269
+ font-size: 12px;
270
+ }
271
+
272
+ #personality-mode .wrap {
273
+ display: flex !important;
274
+ flex-wrap: wrap !important;
275
+ gap: 10px !important;
276
+ }
277
+
278
+ #personality-mode label {
279
+ flex: 1 1 120px;
280
+ min-height: 48px;
281
+ padding: 13px 10px !important;
282
+ border: 1px solid var(--ov-border-light) !important;
283
+ border-radius: 6px !important;
284
+ background: transparent !important;
285
+ text-align: center;
286
+ cursor: pointer;
287
+ }
288
+
289
+ #personality-mode label span {
290
+ display: block;
291
+ color: var(--ov-text-main) !important;
292
+ font-family: var(--font-typewriter);
293
+ font-size: 14px;
294
+ }
295
+
296
+ #personality-mode label:has(input:checked) {
297
+ border-color: var(--ov-gold) !important;
298
+ background: rgba(212, 175, 55, 0.06) !important;
299
+ box-shadow: 0 0 0 1px var(--ov-gold) inset;
300
+ }
301
+
302
+ #personality-mode label:has(input:checked) span {
303
+ color: var(--ov-gold-bright) !important;
304
+ }
305
+
306
+ #wake-button {
307
+ width: 100%;
308
+ margin-top: 22px;
309
+ padding: 18px !important;
310
+ border: 0 !important;
311
+ border-radius: 4px !important;
312
+ background: linear-gradient(180deg, #d8ac54 0%, #a67c2d 100%) !important;
313
+ color: var(--ov-text-dark) !important;
314
+ font-family: var(--font-typewriter);
315
+ font-size: 18px !important;
316
+ font-weight: 700;
317
+ box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.28), 0 4px 14px rgba(0, 0, 0, 0.34) !important;
318
+ }
319
+
320
+ #wake-button:hover {
321
+ filter: brightness(1.08);
322
+ transform: translateY(-1px);
323
+ }
324
+
325
+ .example-header {
326
+ margin-bottom: 18px;
327
+ padding-bottom: 14px;
328
+ border-bottom: 1px solid var(--ov-border-faint);
329
+ }
330
+
331
+ .example-header strong {
332
+ display: block;
333
+ color: var(--ov-text-main) !important;
334
+ font-family: var(--font-typewriter);
335
+ font-size: 16px;
336
+ font-weight: 400;
337
+ }
338
+
339
+ .example-header span {
340
+ color: var(--ov-text-muted) !important;
341
+ font-size: 13px;
342
+ }
343
+
344
+ button.example-card {
345
+ display: block;
346
+ width: 100%;
347
+ margin-bottom: 10px !important;
348
+ padding: 14px !important;
349
+ border: 1px solid var(--ov-border-faint) !important;
350
+ border-radius: 4px !important;
351
+ background: var(--ov-bg-input) !important;
352
+ color: var(--ov-text-main) !important;
353
+ font-family: var(--font-typewriter) !important;
354
+ text-align: left !important;
355
+ transition: border-color 0.2s, background 0.2s;
356
+ }
357
+
358
+ button.example-card:hover,
359
+ button.example-card:focus {
360
+ border-color: var(--ov-gold) !important;
361
+ }
362
+
363
+ button.example-card * {
364
+ color: inherit !important;
365
+ }
366
+
367
+ .panel-header {
368
+ display: grid;
369
+ grid-template-columns: auto 1fr;
370
+ gap: 14px;
371
+ margin-bottom: 18px;
372
+ padding-bottom: 14px;
373
+ border-bottom: 1px solid var(--ov-border-faint);
374
+ }
375
+
376
+ .panel-header > span {
377
+ color: var(--ov-gold) !important;
378
+ font-family: var(--font-typewriter);
379
+ font-size: 16px;
380
+ }
381
+
382
+ .panel-header h2 {
383
+ margin: 0 0 4px;
384
+ color: var(--ov-text-main) !important;
385
+ font-family: var(--font-typewriter);
386
+ font-size: 22px;
387
+ line-height: 1.2;
388
+ }
389
+
390
+ .panel-header h2 small,
391
+ .panel-header p {
392
+ color: var(--ov-text-muted) !important;
393
+ }
394
+
395
+ .panel-header p {
396
+ margin: 0;
397
+ font-size: 13px;
398
+ line-height: 1.45;
399
+ }
400
+
401
+ #diary-output,
402
+ .diary-entry {
403
+ color: var(--ov-text-soft) !important;
404
+ font-family: var(--font-serif) !important;
405
+ font-size: 17px;
406
+ line-height: 1.75;
407
+ }
408
+
409
+ .diary-entry h2,
410
+ #diary-output h2,
411
+ #diary-output h3 {
412
+ margin: 0 0 14px;
413
+ color: var(--ov-gold) !important;
414
+ font-family: var(--font-typewriter);
415
+ font-size: 17px;
416
+ line-height: 1.3;
417
+ text-transform: uppercase;
418
+ }
419
+
420
+ .diary-entry p {
421
+ margin: 0;
422
+ }
423
+
424
+ .zh-helper {
425
+ margin-top: 18px;
426
+ padding-top: 14px;
427
+ border-top: 1px dashed var(--ov-border-faint);
428
+ color: var(--ov-text-muted) !important;
429
+ font-family: var(--font-sans);
430
+ font-size: 14px;
431
+ line-height: 1.7;
432
+ }
433
+
434
+ .archive-empty,
435
+ .objectverse-placeholder {
436
+ padding: 34px 24px;
437
+ border: 1px dashed var(--ov-border-light);
438
+ color: var(--ov-text-muted) !important;
439
+ text-align: center;
440
+ }
441
+
442
+ .archive-empty h3,
443
+ .objectverse-placeholder strong {
444
+ display: block;
445
+ margin: 8px 0;
446
+ color: var(--ov-text-main) !important;
447
+ font-family: var(--font-typewriter);
448
+ }
449
+
450
+ .archive-empty p,
451
+ .objectverse-placeholder p {
452
+ margin: 6px 0 0;
453
+ color: var(--ov-text-muted) !important;
454
+ }
455
+
456
+ .object-file-card,
457
+ .trace-card,
458
+ .archive-error,
459
+ .objectverse-card {
460
+ color: var(--ov-text-main) !important;
461
+ }
462
+
463
+ .object-file-card h3,
464
+ .trace-card strong,
465
+ .archive-error strong,
466
+ .objectverse-card h2 {
467
+ color: var(--ov-text-main) !important;
468
+ }
469
+
470
+ .file-meta {
471
+ display: flex;
472
+ flex-wrap: wrap;
473
+ gap: 8px;
474
+ margin-bottom: 12px;
475
+ }
476
+
477
+ .file-meta span,
478
+ .object-name,
479
+ .object-file-card dt,
480
+ .object-file-card p,
481
+ .object-file-card li,
482
+ .trace-card p,
483
+ .archive-error p,
484
+ .card-kicker,
485
+ .card-object,
486
+ .card-cn {
487
+ color: var(--ov-text-muted) !important;
488
+ }
489
+
490
+ .object-file-card dl {
491
+ display: grid;
492
+ gap: 12px;
493
+ margin: 18px 0;
494
+ }
495
+
496
+ .object-file-card dl > div {
497
+ padding-bottom: 10px;
498
+ border-bottom: 1px solid var(--ov-border-faint);
499
+ }
500
+
501
+ .object-file-card dt {
502
+ margin-bottom: 4px;
503
+ font-family: var(--font-typewriter);
504
+ font-size: 12px;
505
+ text-transform: uppercase;
506
+ }
507
+
508
+ .object-file-card dd {
509
+ margin: 0;
510
+ color: var(--ov-text-soft) !important;
511
+ }
512
+
513
+ .feature-list strong,
514
+ .card-quote {
515
+ color: var(--ov-text-soft) !important;
516
+ }
517
+
518
+ .feature-list ul {
519
+ margin: 8px 0 0;
520
+ padding-left: 20px;
521
+ }
522
+
523
+ .complaint,
524
+ .card-stamp {
525
+ color: var(--ov-gold) !important;
526
+ }
527
+
528
+ .file-tags,
529
+ .card-tags {
530
+ display: flex;
531
+ flex-wrap: wrap;
532
+ gap: 8px;
533
+ margin-top: 16px;
534
+ }
535
+
536
+ .file-tags span,
537
+ .card-tags span {
538
+ padding: 4px 8px;
539
+ border: 1px solid var(--ov-border-light) !important;
540
+ border-radius: 999px;
541
+ color: var(--ov-gold-bright) !important;
542
+ font-family: var(--font-typewriter);
543
+ font-size: 12px;
544
+ }
545
+
546
+ .objectverse-card {
547
+ max-width: 520px;
548
+ padding: 22px;
549
+ border: 1px solid var(--ov-border-light);
550
+ border-radius: 8px;
551
+ background: rgba(22, 21, 19, 0.7);
552
+ }
553
+
554
+ .card-header {
555
+ display: flex;
556
+ justify-content: space-between;
557
+ gap: 16px;
558
+ margin-bottom: 16px;
559
+ }
560
+
561
+ .card-header h2 {
562
+ margin: 4px 0 0;
563
+ font-family: var(--font-typewriter);
564
+ }
565
+
566
+ .card-quote {
567
+ font-family: var(--font-serif);
568
+ font-size: 17px;
569
+ line-height: 1.65;
570
+ }
571
+
572
+ .card-cn {
573
+ margin-top: 12px;
574
+ padding-top: 12px;
575
+ border-top: 1px dashed var(--ov-border-faint);
576
+ }
577
+
578
+ .quiet-button {
579
+ border: 1px solid var(--ov-border-light) !important;
580
+ border-radius: 4px !important;
581
+ background: transparent !important;
582
+ color: var(--ov-gold) !important;
583
+ font-family: var(--font-typewriter) !important;
584
+ }
585
+
586
+ .developer-details {
587
+ border: 1px solid var(--ov-border-faint) !important;
588
+ border-radius: 8px !important;
589
+ background: rgba(30, 28, 25, 0.42) !important;
590
+ }
591
+
592
+ .developer-json-grid {
593
+ gap: 16px !important;
594
+ }
595
+
596
+ .gradio-container :is(summary, pre, code) {
597
+ color: var(--ov-text-main) !important;
598
+ }
599
+
600
+ .gradio-container :is(.json-holder, .json-holder *, .chatbot, .chatbot *) {
601
+ color: var(--ov-text-main) !important;
602
+ }
603
+
604
+ @media (max-width: 980px) {
605
+ #app-container {
606
+ padding: 28px 18px 44px;
607
+ }
608
+
609
+ #objectverse-hero,
610
+ .content-section,
611
+ .split-section,
612
+ .developer-json-grid {
613
+ flex-direction: column !important;
614
+ }
615
+
616
+ .top-controls {
617
+ width: 100%;
618
+ justify-content: space-between;
619
+ }
620
+ }
621
+
622
+ @media (max-width: 600px) {
623
+ #app-container {
624
+ padding: 22px 14px 36px;
625
+ }
626
+
627
+ .archive-panel {
628
+ padding: 18px;
629
+ }
630
+
631
+ .hero-copy h1 {
632
+ font-size: 30px;
633
+ word-break: break-word;
634
+ }
635
+
636
+ .hero-kicker {
637
+ font-size: 15px;
638
+ }
639
+
640
+ .hero-feature {
641
+ font-size: 14px;
642
+ line-height: 1.55;
643
+ }
644
+
645
+ #personality-mode label {
646
+ flex: 1 1 45% !important;
647
+ padding: 10px 6px !important;
648
+ }
649
+ }
src/utils/json_repair.py CHANGED
@@ -1,4 +1,4 @@
1
- """JSON repair placeholder for later model integration."""
2
 
3
  from __future__ import annotations
4
 
@@ -14,17 +14,61 @@ def parse_json_object(raw: str) -> dict[str, Any]:
14
 
15
 
16
  def _extract_json_object(raw: str) -> str:
17
- clean = raw.strip()
18
- if clean.startswith("```"):
19
- clean = clean.strip("`").strip()
20
- if clean.lower().startswith("json"):
21
- clean = clean[4:].strip()
22
 
23
- if clean.startswith("{") and clean.endswith("}"):
 
 
 
 
 
 
 
24
  return clean
25
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  start = clean.find("{")
27
- end = clean.rfind("}")
28
- if start == -1 or end == -1 or end <= start:
29
- raise ValueError("No JSON object found.")
30
- return clean[start : end + 1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small JSON object extraction helpers for model output."""
2
 
3
  from __future__ import annotations
4
 
 
14
 
15
 
16
  def _extract_json_object(raw: str) -> str:
17
+ clean = _strip_markdown_fence(raw.strip())
 
 
 
 
18
 
19
+ candidate = _scan_json_object(clean)
20
+ if candidate is None:
21
+ raise ValueError("No JSON object found.")
22
+ return candidate
23
+
24
+
25
+ def _strip_markdown_fence(clean: str) -> str:
26
+ if not clean.startswith("```"):
27
  return clean
28
 
29
+ lines = clean.splitlines()
30
+ if not lines:
31
+ return clean
32
+
33
+ if lines[0].strip().startswith("```"):
34
+ lines = lines[1:]
35
+ if lines and lines[-1].strip().startswith("```"):
36
+ lines = lines[:-1]
37
+ return "\n".join(lines).strip()
38
+
39
+
40
+ def _scan_json_object(clean: str) -> str | None:
41
  start = clean.find("{")
42
+ if start == -1:
43
+ return None
44
+
45
+ stack: list[str] = []
46
+ in_string = False
47
+ escaped = False
48
+
49
+ for index, char in enumerate(clean[start:], start=start):
50
+ if in_string:
51
+ if escaped:
52
+ escaped = False
53
+ elif char == "\\":
54
+ escaped = True
55
+ elif char == '"':
56
+ in_string = False
57
+ continue
58
+
59
+ if char == '"':
60
+ in_string = True
61
+ elif char == "{":
62
+ stack.append("}")
63
+ elif char == "[":
64
+ stack.append("]")
65
+ elif char in {"}", "]"}:
66
+ if not stack or stack[-1] != char:
67
+ return None
68
+ stack.pop()
69
+ if not stack:
70
+ return clean[start : index + 1]
71
+
72
+ if in_string or not stack:
73
+ return None
74
+ return clean[start:] + "".join(reversed(stack))
tests/test_dataset_tooling.py CHANGED
@@ -5,12 +5,13 @@ from __future__ import annotations
5
  import json
6
  import tempfile
7
  import unittest
 
8
  from pathlib import Path
9
 
10
  from scripts.export_traces import export_trace_jsonl
11
  from scripts.generate_dataset import build_sft_records, write_sft_jsonl
12
  from scripts.generate_sample_traces import generate_sample_traces
13
- from scripts.prepare_curated_dataset import build_curated_records, write_jsonl
14
  from src.models.schema import TraceRecord
15
 
16
 
@@ -48,6 +49,25 @@ class DatasetToolingTest(unittest.TestCase):
48
  self.assertIn("persona", assistant_payload)
49
  self.assertIn("diary", assistant_payload)
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def test_write_curated_jsonl(self) -> None:
52
  with tempfile.TemporaryDirectory() as tmp_dir:
53
  output_path = Path(tmp_dir) / "curated.jsonl"
 
5
  import json
6
  import tempfile
7
  import unittest
8
+ from collections import Counter
9
  from pathlib import Path
10
 
11
  from scripts.export_traces import export_trace_jsonl
12
  from scripts.generate_dataset import build_sft_records, write_sft_jsonl
13
  from scripts.generate_sample_traces import generate_sample_traces
14
+ from scripts.prepare_curated_dataset import MODES, build_curated_records, write_jsonl
15
  from src.models.schema import TraceRecord
16
 
17
 
 
49
  self.assertIn("persona", assistant_payload)
50
  self.assertIn("diary", assistant_payload)
51
 
52
+ def test_build_curated_v2_records_has_broader_balanced_coverage(self) -> None:
53
+ records = build_curated_records(200, version="v2")
54
+ object_names = [
55
+ record["object_understanding"]["object"]["name"]
56
+ for record in records
57
+ ]
58
+ mode_counts = Counter(record["mode"] for record in records)
59
+ object_mode_pairs = {(name, record["mode"]) for name, record in zip(object_names, records)}
60
+ assistant_payload = json.loads(records[0]["messages"][2]["content"])
61
+
62
+ self.assertEqual(len(records), 200)
63
+ self.assertEqual(records[0]["source"], "objectverse-diary-synthetic-curated-v2")
64
+ self.assertGreaterEqual(len(set(object_names)), 40)
65
+ self.assertEqual(mode_counts, Counter({mode: 40 for mode in MODES}))
66
+ self.assertEqual(len(object_mode_pairs), 200)
67
+ self.assertIn("scene_detail", records[0])
68
+ self.assertIn("persona", assistant_payload)
69
+ self.assertIn("diary", assistant_payload)
70
+
71
  def test_write_curated_jsonl(self) -> None:
72
  with tempfile.TemporaryDirectory() as tmp_dir:
73
  output_path = Path(tmp_dir) / "curated.jsonl"
tests/test_finetune_lora_tooling.py CHANGED
@@ -10,6 +10,41 @@ from pathlib import Path
10
  from scripts import finetune_lora
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def _valid_record() -> dict[str, object]:
14
  return {
15
  "id": "sft-preview-0001",
@@ -55,9 +90,38 @@ class FinetuneLoraToolingTest(unittest.TestCase):
55
  self.assertEqual(config.lora_alpha, 32)
56
  self.assertEqual(config.lora_dropout, 0.05)
57
  self.assertEqual(config.max_steps, 80)
 
 
 
 
 
58
  self.assertIn("q_proj", config.target_modules)
59
  self.assertIn("down_proj", config.target_modules)
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def test_dry_run_does_not_call_remote_runner(self) -> None:
62
  with tempfile.TemporaryDirectory() as tmp_dir:
63
  path = Path(tmp_dir) / "records.jsonl"
@@ -80,6 +144,44 @@ class FinetuneLoraToolingTest(unittest.TestCase):
80
  self.assertEqual(summary["mode"], "dry-run")
81
  self.assertEqual(summary["record_count"], 1)
82
  self.assertEqual(summary["base_model"], "Qwen/Qwen2.5-1.5B-Instruct")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
 
85
  if __name__ == "__main__":
 
10
  from scripts import finetune_lora
11
 
12
 
13
+ class FakeTokenizer:
14
+ pad_token = "<pad>"
15
+ eos_token = "</s>"
16
+
17
+ def apply_chat_template(
18
+ self,
19
+ messages: list[dict[str, str]],
20
+ *,
21
+ tokenize: bool,
22
+ add_generation_prompt: bool,
23
+ ) -> str:
24
+ text = "".join(
25
+ f"<{message['role']}>{message['content']}</{message['role']}>"
26
+ for message in messages
27
+ )
28
+ if add_generation_prompt:
29
+ text += "<assistant>"
30
+ return text
31
+
32
+ def __call__(
33
+ self,
34
+ text: str,
35
+ *,
36
+ truncation: bool,
37
+ max_length: int,
38
+ padding: bool,
39
+ add_special_tokens: bool = False,
40
+ ) -> dict[str, list[int]]:
41
+ del padding, add_special_tokens
42
+ ids = [ord(character) % 251 + 1 for character in text]
43
+ if truncation:
44
+ ids = ids[:max_length]
45
+ return {"input_ids": ids, "attention_mask": [1] * len(ids)}
46
+
47
+
48
  def _valid_record() -> dict[str, object]:
49
  return {
50
  "id": "sft-preview-0001",
 
90
  self.assertEqual(config.lora_alpha, 32)
91
  self.assertEqual(config.lora_dropout, 0.05)
92
  self.assertEqual(config.max_steps, 80)
93
+ self.assertEqual(config.num_train_epochs, 3.0)
94
+ self.assertEqual(config.per_device_train_batch_size, 1)
95
+ self.assertEqual(config.gradient_accumulation_steps, 4)
96
+ self.assertEqual(config.eval_ratio, 0.1)
97
+ self.assertTrue(config.assistant_only_loss)
98
  self.assertIn("q_proj", config.target_modules)
99
  self.assertIn("down_proj", config.target_modules)
100
 
101
+ def test_training_config_serializes_v2_experiment_settings(self) -> None:
102
+ config = finetune_lora.TrainingConfig(
103
+ max_steps=0,
104
+ num_train_epochs=4.0,
105
+ per_device_train_batch_size=2,
106
+ gradient_accumulation_steps=8,
107
+ eval_ratio=0.2,
108
+ eval_steps=25,
109
+ lora_r=32,
110
+ lora_alpha=64,
111
+ assistant_only_loss=False,
112
+ )
113
+
114
+ payload = config.as_remote_dict()
115
+
116
+ self.assertEqual(payload["num_train_epochs"], 4.0)
117
+ self.assertEqual(payload["per_device_train_batch_size"], 2)
118
+ self.assertEqual(payload["gradient_accumulation_steps"], 8)
119
+ self.assertEqual(payload["eval_ratio"], 0.2)
120
+ self.assertEqual(payload["eval_steps"], 25)
121
+ self.assertEqual(payload["lora_r"], 32)
122
+ self.assertEqual(payload["lora_alpha"], 64)
123
+ self.assertFalse(payload["assistant_only_loss"])
124
+
125
  def test_dry_run_does_not_call_remote_runner(self) -> None:
126
  with tempfile.TemporaryDirectory() as tmp_dir:
127
  path = Path(tmp_dir) / "records.jsonl"
 
144
  self.assertEqual(summary["mode"], "dry-run")
145
  self.assertEqual(summary["record_count"], 1)
146
  self.assertEqual(summary["base_model"], "Qwen/Qwen2.5-1.5B-Instruct")
147
+ self.assertEqual(summary["train_record_count"], 1)
148
+ self.assertEqual(summary["eval_record_count"], 0)
149
+
150
+ def test_dry_run_reports_eval_split_for_larger_datasets(self) -> None:
151
+ records = [_valid_record() for _ in range(20)]
152
+ summary = finetune_lora._dry_run_summary(
153
+ Path("records.jsonl"),
154
+ records,
155
+ finetune_lora.TrainingConfig(eval_ratio=0.2),
156
+ )
157
+
158
+ self.assertEqual(summary["train_record_count"], 16)
159
+ self.assertEqual(summary["eval_record_count"], 4)
160
+
161
+ def test_assistant_only_tokenization_masks_prompt_labels(self) -> None:
162
+ tokenized = finetune_lora._tokenize_training_example(
163
+ _valid_record(),
164
+ FakeTokenizer(),
165
+ max_length=512,
166
+ assistant_only_loss=True,
167
+ )
168
+
169
+ labels = tokenized["labels"]
170
+
171
+ self.assertIn(-100, labels)
172
+ self.assertTrue(any(label != -100 for label in labels))
173
+ first_unmasked = next(index for index, label in enumerate(labels) if label != -100)
174
+ self.assertGreater(first_unmasked, 0)
175
+
176
+ def test_full_loss_tokenization_keeps_all_labels(self) -> None:
177
+ tokenized = finetune_lora._tokenize_training_example(
178
+ _valid_record(),
179
+ FakeTokenizer(),
180
+ max_length=512,
181
+ assistant_only_loss=False,
182
+ )
183
+
184
+ self.assertNotIn(-100, tokenized["labels"])
185
 
186
 
187
  if __name__ == "__main__":
tests/test_json_repair.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for tolerant JSON object extraction from model output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import unittest
6
+
7
+ from src.utils.json_repair import parse_json_object
8
+
9
+
10
+ class JsonRepairTest(unittest.TestCase):
11
+ def test_parses_complete_object_with_surrounding_text(self) -> None:
12
+ payload = parse_json_object('Here is the archive:\n{"name": "mug"}\nDone.')
13
+
14
+ self.assertEqual(payload, {"name": "mug"})
15
+
16
+ def test_repairs_missing_outer_closing_brace(self) -> None:
17
+ payload = parse_json_object(
18
+ """
19
+ {
20
+ "persona": {"object_name": "coffee mug"},
21
+ "diary": {"title": "Secret Diary - Day 310"}
22
+ """
23
+ )
24
+
25
+ self.assertEqual(payload["persona"]["object_name"], "coffee mug")
26
+ self.assertEqual(payload["diary"]["title"], "Secret Diary - Day 310")
27
+
28
+ def test_does_not_repair_unterminated_string(self) -> None:
29
+ with self.assertRaises(ValueError):
30
+ parse_json_object('{"name": "coffee mug}')
31
+
32
+
33
+ if __name__ == "__main__":
34
+ unittest.main()
tests/test_llama_cpp_smoke.py CHANGED
@@ -24,10 +24,7 @@ class LlamaCppSmokeTest(unittest.TestCase):
24
  fake_llama = FakeLlamaModel(
25
  [
26
  """
27
- {"persona":{"object_name":"coffee mug","character_name":"Mugworth","mood":"dry and suspicious","secret_fear":"being left empty forever","core_memory":"It remembers every late-night refill.","complaint":"I am treated like a ceramic fuel tank.","tags":["desk witness","warm archive","quiet judgment"]}}
28
- """,
29
- """
30
- {"title":"Secret Diary - Day 418","english":"Today I held another bitter storm and called it service.","chinese":"今天我又装下一场苦涩风暴,并被称为有用。"}
31
  """,
32
  """
33
  {"reply":"Mugworth: I saw another deadline dissolve into a coffee ring."}
 
24
  fake_llama = FakeLlamaModel(
25
  [
26
  """
27
+ {"persona":{"object_name":"coffee mug","character_name":"Mugworth","mood":"dry and suspicious","secret_fear":"being left empty forever","core_memory":"It remembers every late-night refill.","complaint":"I am treated like a ceramic fuel tank.","tags":["desk witness","warm archive","quiet judgment"]},"diary":{"title":"Secret Diary - Day 418","english":"Today I held another bitter storm and called it service.","chinese":"今天我又装下一场苦涩风暴,并被称为有用。"}}
 
 
 
28
  """,
29
  """
30
  {"reply":"Mugworth: I saw another deadline dissolve into a coffee ring."}
tests/test_merge_lora_adapter.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for LoRA merge helper tooling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ from scripts.merge_lora_adapter import plan_merge, validate_adapter_source
11
+
12
+
13
+ class MergeLoraAdapterTest(unittest.TestCase):
14
+ def test_validate_local_adapter_requires_config_and_weights(self) -> None:
15
+ with tempfile.TemporaryDirectory() as tmp_dir:
16
+ adapter_dir = Path(tmp_dir)
17
+ (adapter_dir / "adapter_config.json").write_text("{}", encoding="utf-8")
18
+
19
+ with self.assertRaises(ValueError):
20
+ validate_adapter_source(adapter_dir, base_model="Qwen/Qwen2.5-1.5B-Instruct")
21
+
22
+ def test_plan_merge_dry_run_returns_summary_without_loading_model(self) -> None:
23
+ with tempfile.TemporaryDirectory() as tmp_dir:
24
+ adapter_dir = Path(tmp_dir) / "adapter"
25
+ adapter_dir.mkdir()
26
+ (adapter_dir / "adapter_config.json").write_text(
27
+ json.dumps({"base_model_name_or_path": "Qwen/Qwen2.5-1.5B-Instruct"}),
28
+ encoding="utf-8",
29
+ )
30
+ (adapter_dir / "adapter_model.safetensors").write_text("fake", encoding="utf-8")
31
+
32
+ summary = plan_merge(
33
+ base_model="Qwen/Qwen2.5-1.5B-Instruct",
34
+ adapter=adapter_dir,
35
+ output=Path(tmp_dir) / "merged",
36
+ dry_run=True,
37
+ )
38
+
39
+ self.assertTrue(summary["dry_run"])
40
+ self.assertFalse(summary["merged"])
41
+ self.assertEqual(summary["base_model"], "Qwen/Qwen2.5-1.5B-Instruct")
42
+ self.assertEqual(summary["adapter_type"], "local")
43
+
44
+
45
+ if __name__ == "__main__":
46
+ unittest.main()
tests/test_publish_hf_dataset.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Hugging Face Dataset publishing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ from scripts.prepare_curated_dataset import build_curated_records, write_jsonl
11
+ from scripts.publish_hf_dataset import upload_dataset, validate_dataset_file
12
+
13
+
14
+ class PublishHfDatasetTest(unittest.TestCase):
15
+ def test_validate_dataset_file_rejects_bad_assistant_json(self) -> None:
16
+ with tempfile.TemporaryDirectory() as tmp_dir:
17
+ dataset_file = Path(tmp_dir) / "bad.jsonl"
18
+ dataset_file.write_text(
19
+ json.dumps(
20
+ {
21
+ "id": "bad",
22
+ "messages": [
23
+ {"role": "system", "content": "system"},
24
+ {"role": "user", "content": "user"},
25
+ {"role": "assistant", "content": "not json"},
26
+ ],
27
+ }
28
+ )
29
+ + "\n",
30
+ encoding="utf-8",
31
+ )
32
+
33
+ with self.assertRaises(ValueError):
34
+ validate_dataset_file(dataset_file)
35
+
36
+ def test_upload_dataset_dry_run_returns_file_summary(self) -> None:
37
+ with tempfile.TemporaryDirectory() as tmp_dir:
38
+ dataset_file = Path(tmp_dir) / "curated_v2.jsonl"
39
+ write_jsonl(build_curated_records(5, version="v2"), dataset_file)
40
+
41
+ summary = upload_dataset(
42
+ dataset_file=dataset_file,
43
+ repo_id="qqyule/objectverse-diary-sft-curated",
44
+ path_in_repo="objectverse_sft_curated_v2.jsonl",
45
+ private=False,
46
+ commit_message="Dry run",
47
+ dry_run=True,
48
+ )
49
+
50
+ self.assertFalse(summary["uploaded"])
51
+ self.assertEqual(summary["repo_id"], "qqyule/objectverse-diary-sft-curated")
52
+ self.assertEqual(summary["path_in_repo"], "objectverse_sft_curated_v2.jsonl")
53
+ self.assertEqual(summary["record_count"], 5)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
tests/test_publish_hf_gguf.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Hugging Face GGUF publishing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from scripts.publish_hf_gguf import upload_gguf, validate_gguf_file
10
+
11
+
12
+ class PublishHfGgufTest(unittest.TestCase):
13
+ def test_validate_gguf_file_requires_gguf_suffix(self) -> None:
14
+ with tempfile.TemporaryDirectory() as tmp_dir:
15
+ model_file = Path(tmp_dir) / "model.bin"
16
+ model_file.write_text("fake", encoding="utf-8")
17
+
18
+ with self.assertRaises(ValueError):
19
+ validate_gguf_file(model_file)
20
+
21
+ def test_upload_gguf_dry_run_returns_file_summary(self) -> None:
22
+ with tempfile.TemporaryDirectory() as tmp_dir:
23
+ gguf_file = Path(tmp_dir) / "model.gguf"
24
+ gguf_file.write_bytes(b"fake-gguf")
25
+
26
+ summary = upload_gguf(
27
+ gguf_file=gguf_file,
28
+ repo_id="qqyule/objectverse-diary-qwen15b-lora",
29
+ path_in_repo="objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf",
30
+ private=False,
31
+ commit_message="Dry run",
32
+ dry_run=True,
33
+ )
34
+
35
+ self.assertFalse(summary["uploaded"])
36
+ self.assertEqual(summary["repo_id"], "qqyule/objectverse-diary-qwen15b-lora")
37
+ self.assertEqual(
38
+ summary["path_in_repo"],
39
+ "objectverse-diary-qwen15b-lora-v2-q4_k_m.gguf",
40
+ )
41
+ self.assertEqual(summary["size_bytes"], 9)
42
+
43
+
44
+ if __name__ == "__main__":
45
+ unittest.main()