qqyule commited on
Commit
9e874de
·
verified ·
1 Parent(s): 1e2c036

Update Objectverse Diary submission package

Browse files
README.md CHANGED
@@ -23,13 +23,13 @@ Upload a photo of any everyday object. The app wakes it up, gives it a secret pe
23
 
24
  ## Current Status
25
 
26
- Stable mock-safe submission baseline, MiniCPM-V vision backend wiring, optional llama.cpp text runtime wiring, public mock traces, and Space validation evidence are available.
27
 
28
  By default, the app uses deterministic mock outputs for object understanding, persona generation, diary writing, chat replies, share card rendering, and trace saving. This keeps the public demo reproducible and avoids commercial AI APIs.
29
 
30
  `OBJECTVERSE_VISION_BACKEND=minicpm-v` enables the optional MiniCPM-V 2.6 vision path. The hosted ZeroGPU validation on June 8, 2026 reached the Space but fell back to mock vision for all three public test images; this is documented in `docs/SPACE_VLM_REPORT.md` and `docs/FAILURES.md`.
31
 
32
- `OBJECTVERSE_TEXT_BACKEND=llama-cpp` can use a local GGUF model through optional `llama-cpp-python` when `TEXT_MODEL_PATH` is configured. No GGUF file or fine-tuned model is committed in this stable submission baseline.
33
 
34
  Hugging Face Space:
35
 
@@ -60,13 +60,13 @@ The interface is English-first and Chinese-second.
60
  - [x] Field Notes — article draft in `docs/FIELD_NOTES.md`.
61
  - [ ] OpenBMB Special — MiniCPM-V wiring exists, but hosted validation currently falls back to mock vision.
62
  - [ ] Llama Champion — llama.cpp wiring exists, but real GGUF smoke test is not complete.
63
- - [ ] Well-Tuned — dataset preview exists, but LoRA training/model publishing is not complete.
64
  - [ ] Off the Grid — no commercial AI APIs are used; final badge eligibility depends on hackathon review.
65
 
66
  ## Planned Model Stack
67
 
68
  - Vision: MiniCPM-V 2.6 or deterministic mock fallback
69
- - Text: deterministic mock text now; optional GGUF later
70
  - Runtime: llama.cpp / llama-cpp-python
71
  - UI: Gradio Blocks
72
 
@@ -79,9 +79,10 @@ Stable baseline:
79
  - default vision backend: deterministic mock, 0 active model parameters
80
  - default text backend: deterministic mock, 0 active model parameters
81
  - optional wired vision model: MiniCPM-V 2.6, about 8B parameters when enabled
82
- - optional text GGUF: not selected or committed yet
 
83
 
84
- The stable public demo therefore stays within the 32B budget. Future GGUF or LoRA work must update `docs/MODEL_CARD.md` before being claimed in submission materials.
85
 
86
  ## Run Locally
87
 
@@ -127,6 +128,8 @@ The stable submission baseline supports:
127
  - Initial acceptance report: `docs/INITIAL_STAGE_REPORT.md`
128
  - Runtime notes: `docs/RUNTIME.md`
129
  - Dataset preview notes: `docs/DATASET.md`
 
 
130
  - Public mock traces: `data/traces/samples/`
131
  - Trace JSONL export: `data/traces/samples/objectverse_public_mock_traces.jsonl`
132
  - Hosted VLM failure evidence: `docs/SPACE_VLM_REPORT.md`, `docs/SPACE_VLM_REPORT.json`, `data/traces/space-vlm/`
 
23
 
24
  ## Current Status
25
 
26
+ Stable mock-safe submission baseline, MiniCPM-V vision backend wiring, optional llama.cpp text runtime wiring, public mock traces, Space validation evidence, and a published Qwen 1.5B LoRA test adapter are available.
27
 
28
  By default, the app uses deterministic mock outputs for object understanding, persona generation, diary writing, chat replies, share card rendering, and trace saving. This keeps the public demo reproducible and avoids commercial AI APIs.
29
 
30
  `OBJECTVERSE_VISION_BACKEND=minicpm-v` enables the optional MiniCPM-V 2.6 vision path. The hosted ZeroGPU validation on June 8, 2026 reached the Space but fell back to mock vision for all three public test images; this is documented in `docs/SPACE_VLM_REPORT.md` and `docs/FAILURES.md`.
31
 
32
+ `OBJECTVERSE_TEXT_BACKEND=llama-cpp` can use a local GGUF model through optional `llama-cpp-python` when `TEXT_MODEL_PATH` is configured. No GGUF file is committed in this stable submission baseline. A short Modal-trained LoRA adapter is published for Well-Tuned evidence, but it is not converted to GGUF or wired into the public Space runtime yet.
33
 
34
  Hugging Face Space:
35
 
 
60
  - [x] Field Notes — article draft in `docs/FIELD_NOTES.md`.
61
  - [ ] OpenBMB Special — MiniCPM-V wiring exists, but hosted validation currently falls back to mock vision.
62
  - [ ] Llama Champion — llama.cpp wiring exists, but real GGUF smoke test is not complete.
63
+ - [x] Well-Tuned — synthetic curated SFT dataset and Qwen 1.5B LoRA test adapter are published.
64
  - [ ] Off the Grid — no commercial AI APIs are used; final badge eligibility depends on hackathon review.
65
 
66
  ## Planned Model Stack
67
 
68
  - Vision: MiniCPM-V 2.6 or deterministic mock fallback
69
+ - Text: deterministic mock text now; published Qwen 1.5B LoRA test adapter for training evidence; optional GGUF later
70
  - Runtime: llama.cpp / llama-cpp-python
71
  - UI: Gradio Blocks
72
 
 
79
  - default vision backend: deterministic mock, 0 active model parameters
80
  - default text backend: deterministic mock, 0 active model parameters
81
  - optional wired vision model: MiniCPM-V 2.6, about 8B parameters when enabled
82
+ - optional text base for published LoRA adapter: Qwen/Qwen2.5-1.5B-Instruct, about 1.5B parameters
83
+ - optional text GGUF: not converted or committed yet
84
 
85
+ The stable public demo therefore stays within the 32B budget. Optional MiniCPM-V plus Qwen 1.5B remains about 9.5B plus a small LoRA adapter, safely under the 32B budget.
86
 
87
  ## Run Locally
88
 
 
128
  - Initial acceptance report: `docs/INITIAL_STAGE_REPORT.md`
129
  - Runtime notes: `docs/RUNTIME.md`
130
  - Dataset preview notes: `docs/DATASET.md`
131
+ - Synthetic curated dataset: https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
132
+ - Fine-tuned LoRA adapter: https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
133
  - Public mock traces: `data/traces/samples/`
134
  - Trace JSONL export: `data/traces/samples/objectverse_public_mock_traces.jsonl`
135
  - Hosted VLM failure evidence: `docs/SPACE_VLM_REPORT.md`, `docs/SPACE_VLM_REPORT.json`, `data/traces/space-vlm/`
data/train/objectverse_sft_curated.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
docs/DATASET.md CHANGED
@@ -20,6 +20,22 @@ This preview is mock-generated. It is not a final training dataset and should no
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
  ## Target Dataset
24
 
25
  Final target before fine-tuning:
@@ -70,6 +86,47 @@ Manual curation should happen after generation. Do not publish the full candidat
70
 
71
  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.
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  ## Curation Checklist
74
 
75
  - Persona stays consistent with the object.
 
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 \
27
+ --count 50 \
28
+ --output data/train/objectverse_sft_curated.jsonl
29
+ ```
30
+
31
+ This file is synthetic curated data: hand-shaped, deterministic, privacy-safe, and useful for testing the LoRA pipeline. It is not based on private user photos or commercial AI output.
32
+
33
+ Published synthetic curated dataset:
34
+
35
+ ```text
36
+ https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
37
+ ```
38
+
39
  ## Target Dataset
40
 
41
  Final target before fine-tuning:
 
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
 
89
+ ## Modal LoRA Training Scaffold
90
+
91
+ The repository includes a Modal training scaffold for the future Well-Tuned path. It is not run by default and does not affect the Gradio Space runtime.
92
+
93
+ Install the local Modal CLI dependency separately:
94
+
95
+ ```bash
96
+ pip install -r requirements-training.txt
97
+ ```
98
+
99
+ Validate the local JSONL shape without Modal auth or GPU usage:
100
+
101
+ ```bash
102
+ .venv/bin/python -B scripts/finetune_lora.py \
103
+ --dry-run \
104
+ --dataset data/train/objectverse_sft_curated.jsonl \
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
 
132
  - Persona stays consistent with the object.
docs/DEVELOPMENT_STATUS.md CHANGED
@@ -32,6 +32,10 @@ Last updated: 2026-06-08
32
  - demo video script
33
  - social post draft
34
  - stable submission guide
 
 
 
 
35
  - Local tests and initial acceptance currently pass.
36
 
37
  ## Not Completed
@@ -40,9 +44,8 @@ Last updated: 2026-06-08
40
  - Passing real VLM demo trace capture. Failed Space VLM traces are kept as fallback evidence and do not replace mock sample traces.
41
  - Real GGUF model selection, download/configuration outside Git, and `TEXT_MODEL_PATH` smoke test.
42
  - Final text model parameter count documentation.
43
- - Real model traces and curated object-persona dataset.
44
- - LoRA training, adapter/model export, GGUF conversion, and Hugging Face model publishing.
45
- - Hugging Face dataset publishing.
46
  - GitHub sync / final public repository confirmation.
47
  - Published Field Notes URL, recorded demo video URL, social post URL, and final public submission.
48
 
 
32
  - demo video script
33
  - social post draft
34
  - stable submission guide
35
+ - Well-Tuned evidence:
36
+ - 50-row synthetic curated SFT dataset published at https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
37
+ - Modal Qwen 1.5B LoRA test run completed with 20 steps
38
+ - LoRA adapter published at https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
39
  - Local tests and initial acceptance currently pass.
40
 
41
  ## Not Completed
 
44
  - Passing real VLM demo trace capture. Failed Space VLM traces are kept as fallback evidence and do not replace mock sample traces.
45
  - Real GGUF model selection, download/configuration outside Git, and `TEXT_MODEL_PATH` smoke test.
46
  - Final text model parameter count documentation.
47
+ - Real model traces from non-mock runtime.
48
+ - GGUF conversion and runtime wiring for the published LoRA adapter.
 
49
  - GitHub sync / final public repository confirmation.
50
  - Published Field Notes URL, recorded demo video URL, social post URL, and final public submission.
51
 
docs/MODEL_CARD.md CHANGED
@@ -2,9 +2,9 @@
2
 
3
  ## Status
4
 
5
- Stable submission baseline. No text model has been fine-tuned, converted, or published yet.
6
 
7
- The app defaults to deterministic mock backends. MiniCPM-V 2.6 vision is wired as an optional runtime backend for GPU environments. Text generation has optional llama.cpp wiring for an externally configured GGUF model via `TEXT_MODEL_PATH`.
8
 
9
  Hosted MiniCPM-V validation is not passing yet. The June 8, 2026 ZeroGPU validation reached the Space, but all three public object checks fell back to mock vision. See `docs/SPACE_VLM_REPORT.md` and `docs/FAILURES.md`.
10
 
@@ -19,7 +19,7 @@ Hosted MiniCPM-V validation is not passing yet. The June 8, 2026 ZeroGPU validat
19
  | Component | Candidate | Notes |
20
  | --- | --- | --- |
21
  | Vision | `openbmb/MiniCPM-V-2_6` or mock fallback | Wired as optional backend; hosted validation currently falls back to mock. |
22
- | Text | deterministic mock text; optional externally configured GGUF later | Final base model still pending. |
23
  | Runtime | optional GGUF through llama.cpp / llama-cpp-python | Wired with mock fallback; real-model smoke test still pending. |
24
  | UI | Gradio Blocks | Required by the hackathon and project rules. |
25
 
@@ -33,10 +33,12 @@ Record final numbers here before submission:
33
  | --- | --- | ---: | --- |
34
  | Vision | MiniCPM-V 2.6 optional path | ~8B | yes, when enabled |
35
  | Text base | Stable baseline mock text | 0 | no model parameters |
36
- | Future text base | Externally configured GGUF, final model TBD | TBD | yes, when enabled |
37
- | Future LoRA adapter | TBD | TBD | yes, when enabled |
38
  | Stable baseline total | Mock text + optional wired vision not active by default | 0 active model parameters by default | <= 32B |
39
 
 
 
40
  ## Intended Inputs And Outputs
41
 
42
  Inputs:
@@ -58,7 +60,28 @@ Outputs:
58
 
59
  Dataset planning lives in `docs/DATASET.md`.
60
 
61
- Current preview data is deterministic and mock-generated. It should only be used for schema validation and workflow planning until real candidate samples are generated and curated.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  ## Safety And Privacy
64
 
 
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. 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 is not passing yet. The June 8, 2026 ZeroGPU validation reached the Space, but all three public object checks fell back to mock vision. See `docs/SPACE_VLM_REPORT.md` and `docs/FAILURES.md`.
10
 
 
19
  | Component | Candidate | Notes |
20
  | --- | --- | --- |
21
  | Vision | `openbmb/MiniCPM-V-2_6` or mock fallback | Wired as optional backend; hosted validation currently falls back to mock. |
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; real-model smoke test still pending. |
24
  | UI | Gradio Blocks | Required by the hackathon and project rules. |
25
 
 
33
  | --- | --- | ---: | --- |
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 adapter | `qqyule/objectverse-diary-qwen15b-lora` | small adapter over base model | yes, when enabled |
38
  | Stable baseline total | Mock text + optional wired vision not active by default | 0 active model parameters by default | <= 32B |
39
 
40
+ If the optional MiniCPM-V 2.6 vision path and planned Qwen 1.5B text base are both enabled, the expected total remains about 9.5B plus a small LoRA adapter, safely under the 32B project budget.
41
+
42
  ## Intended Inputs And Outputs
43
 
44
  Inputs:
 
60
 
61
  Dataset planning lives in `docs/DATASET.md`.
62
 
63
+ 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.
64
+
65
+ 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`.
66
+
67
+ Published adapter:
68
+
69
+ ```text
70
+ https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
71
+ ```
72
+
73
+ Training run summary:
74
+
75
+ - Platform: Modal
76
+ - Run name: `objectverse-diary-qwen15b-curated-test`
77
+ - Base model: `Qwen/Qwen2.5-1.5B-Instruct`
78
+ - Dataset: 50 synthetic curated rows
79
+ - Steps: 20
80
+ - Max sequence length: 1024
81
+ - Learning rate: 0.0002
82
+ - LoRA rank / alpha / dropout: 16 / 32 / 0.05
83
+ - Train loss: 1.6697
84
+ - GGUF conversion: not completed
85
 
86
  ## Safety And Privacy
87
 
docs/SUBMISSION_GUIDE.md CHANGED
@@ -6,8 +6,8 @@
6
  - [x] GitHub Repository URL: local `origin` configured as `https://github.com/qqyule/Objectverse-Diary.git`; push still requires explicit confirmation
7
  - [x] Demo Video Script: `docs/DEMO_VIDEO_SCRIPT.md`
8
  - [x] Social Media Post Draft: `docs/SOCIAL_POST.md`
9
- - [ ] Fine-tuned Model URL: not included in stable baseline; LoRA/model publishing remains future work
10
- - [ ] Dataset URL: not included in stable baseline; local mock preview exists
11
  - [x] Trace Dataset: local public mock JSONL export at `data/traces/samples/objectverse_public_mock_traces.jsonl`
12
  - [x] Field Notes Draft: `docs/FIELD_NOTES.md`
13
  - [x] Short project description: available in README
@@ -31,13 +31,15 @@
31
  - MiniCPM-V 2.6 backend wiring with fallback markers.
32
  - Optional llama.cpp text runtime wiring through `TEXT_MODEL_PATH`.
33
  - Hosted Space VLM validation script, report, JSON summary, and trace evidence export.
 
 
34
  - Field Notes draft, demo video script, and social post draft for the stable submission package.
35
 
36
  ## Not Completed Yet
37
 
38
  - Hosted Space MiniCPM-V validation for mug, keyboard, and shoe; ZeroGPU validation reached the app but currently falls back to mock vision.
39
  - Real GGUF `TEXT_MODEL_PATH` smoke test and final text model parameter count.
40
- - Real model traces, curated dataset, LoRA training, model/dataset publishing.
41
  - Field Notes publication URL, recorded demo video URL, social post URL, and final public push/submission.
42
 
43
  ## Final Checks
@@ -48,8 +50,8 @@
48
  - [x] README includes stable-baseline parameter budget and links to the model card.
49
  - [ ] No commercial cloud AI APIs are used.
50
  - [x] Mock-safe local demo baseline is reproducible from committed sample traces.
51
- - [ ] Fine-tuned model is linked.
52
- - [ ] Dataset is linked.
53
  - [ ] Traces are linked.
54
  - [ ] Field Notes are linked.
55
  - [ ] UI remains English-first and Chinese-second.
 
6
  - [x] GitHub Repository URL: local `origin` configured as `https://github.com/qqyule/Objectverse-Diary.git`; push still requires explicit confirmation
7
  - [x] Demo Video Script: `docs/DEMO_VIDEO_SCRIPT.md`
8
  - [x] Social Media Post Draft: `docs/SOCIAL_POST.md`
9
+ - [x] Fine-tuned Model URL: https://huggingface.co/qqyule/objectverse-diary-qwen15b-lora
10
+ - [x] Dataset URL: https://huggingface.co/datasets/qqyule/objectverse-diary-sft-curated
11
  - [x] Trace Dataset: local public mock JSONL export at `data/traces/samples/objectverse_public_mock_traces.jsonl`
12
  - [x] Field Notes Draft: `docs/FIELD_NOTES.md`
13
  - [x] Short project description: available in README
 
31
  - MiniCPM-V 2.6 backend wiring with fallback markers.
32
  - Optional llama.cpp text runtime wiring through `TEXT_MODEL_PATH`.
33
  - Hosted Space VLM validation script, report, JSON summary, and trace evidence export.
34
+ - Synthetic curated SFT dataset published to Hugging Face Datasets.
35
+ - Modal Qwen 1.5B LoRA test run completed and adapter published to Hugging Face Models.
36
  - Field Notes draft, demo video script, and social post draft for the stable submission package.
37
 
38
  ## Not Completed Yet
39
 
40
  - Hosted Space MiniCPM-V validation for mug, keyboard, and shoe; ZeroGPU validation reached the app but currently falls back to mock vision.
41
  - Real GGUF `TEXT_MODEL_PATH` smoke test and final text model parameter count.
42
+ - Real model traces, GGUF conversion, and app runtime wiring for the published adapter.
43
  - Field Notes publication URL, recorded demo video URL, social post URL, and final public push/submission.
44
 
45
  ## Final Checks
 
50
  - [x] README includes stable-baseline parameter budget and links to the model card.
51
  - [ ] No commercial cloud AI APIs are used.
52
  - [x] Mock-safe local demo baseline is reproducible from committed sample traces.
53
+ - [x] Fine-tuned model is linked.
54
+ - [x] Dataset is linked.
55
  - [ ] Traces are linked.
56
  - [ ] Field Notes are linked.
57
  - [ ] UI remains English-first and Chinese-second.
requirements-training.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ modal>=1,<2
2
+ huggingface_hub>=0.34,<1
scripts/README.md CHANGED
@@ -7,15 +7,70 @@ 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
  - `export_traces.py`: exports validated public sample traces to JSONL for dataset-style publishing.
11
  - `check_space_vlm.py`: validates MiniCPM-V object understanding on the hosted Hugging Face Space with three temporary public test images.
 
 
12
 
13
  Expected files during implementation:
14
 
15
- - `finetune_lora.py`
16
  - `convert_to_gguf.sh`
17
  - `run_llama_cpp.sh`
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  Space VLM validation:
20
 
21
  ```bash
@@ -30,4 +85,4 @@ External Space changes are explicit:
30
  .venv/bin/python -B scripts/check_space_vlm.py --configure-space --rollback-to-mock
31
  ```
32
 
33
- Current status: mock trace generation, trace JSONL export, SFT preview generation, optional MiniCPM-V wiring, optional llama.cpp wiring, and hosted Space VLM validation tooling are implemented. Real model validation on Space, fine-tuning, and GGUF conversion 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 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
+ - `finetune_lora.py`: validates SFT JSONL locally and defines the Modal LoRA training scaffold for the future Well-Tuned path.
14
+ - `publish_hf_adapter.py`: uploads a downloaded LoRA adapter folder to Hugging Face Hub.
15
 
16
  Expected files during implementation:
17
 
 
18
  - `convert_to_gguf.sh`
19
  - `run_llama_cpp.sh`
20
 
21
+ Modal LoRA dry-run:
22
+
23
+ ```bash
24
+ .venv/bin/python -B scripts/finetune_lora.py \
25
+ --dry-run \
26
+ --dataset data/train/objectverse_sft_curated.jsonl \
27
+ --run-name objectverse-diary-qwen15b-curated-test
28
+ ```
29
+
30
+ Modal LoRA training after explicit confirmation:
31
+
32
+ ```bash
33
+ modal run scripts/finetune_lora.py \
34
+ --dataset data/train/objectverse_sft_curated.jsonl \
35
+ --run-name objectverse-diary-qwen15b-curated-test \
36
+ --max-steps 20
37
+ ```
38
+
39
+ Training dependencies are intentionally separate from the Space runtime:
40
+
41
+ ```bash
42
+ pip install -r requirements-training.txt
43
+ ```
44
+
45
+ Do not commit Modal credit codes, tokens, Hugging Face tokens, generated adapters, GGUF files, or private datasets.
46
+
47
+ If `modal run` reports `Token missing`, authenticate outside the repository first:
48
+
49
+ ```bash
50
+ modal token new
51
+ ```
52
+
53
+ or configure `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` through your shell/secret manager.
54
+
55
+ 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.
56
+
57
+ ```bash
58
+ mkdir -p exports/objectverse-diary-qwen15b-curated-test-adapter-dir
59
+ 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
60
+ modal volume get objectverse-diary-lora-output \
61
+ "objectverse-diary-qwen15b-curated-test/adapter/$file" \
62
+ "exports/objectverse-diary-qwen15b-curated-test-adapter-dir/$file"
63
+ done
64
+ ```
65
+
66
+ Then upload the adapter to Hugging Face Hub:
67
+
68
+ ```bash
69
+ .venv/bin/python -B scripts/publish_hf_adapter.py \
70
+ --adapter-dir exports/objectverse-diary-qwen15b-curated-test-adapter-dir \
71
+ --repo-id qqyule/objectverse-diary-qwen15b-lora
72
+ ```
73
+
74
  Space VLM validation:
75
 
76
  ```bash
 
85
  .venv/bin/python -B scripts/check_space_vlm.py --configure-space --rollback-to-mock
86
  ```
87
 
88
+ 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, Modal LoRA training scaffolding, one Modal LoRA test run, and HF adapter publishing are implemented. Real model validation on Space, GGUF conversion, and app runtime wiring for the adapter are not completed yet.
scripts/finetune_lora.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal LoRA fine-tuning scaffold for Objectverse Diary text generation."""
2
+
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
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
14
+ if str(PROJECT_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(PROJECT_ROOT))
16
+
17
+ try:
18
+ import modal
19
+ except ImportError: # Modal is optional for local dry-run and tests.
20
+ modal = None # type: ignore[assignment]
21
+
22
+
23
+ APP_NAME = "objectverse-diary-lora"
24
+ DEFAULT_DATASET_PATH = Path("data/train/objectverse_sft_preview.jsonl")
25
+ DEFAULT_RUN_NAME = "objectverse-diary-qwen15b-preview"
26
+ DEFAULT_BASE_MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
27
+ HOURS = 60 * 60
28
+ CACHE_DIR = "/cache"
29
+ OUTPUT_DIR = "/outputs"
30
+
31
+ LORA_TARGET_MODULES = (
32
+ "q_proj",
33
+ "k_proj",
34
+ "v_proj",
35
+ "o_proj",
36
+ "gate_proj",
37
+ "up_proj",
38
+ "down_proj",
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TrainingConfig:
44
+ """Serializable training settings shared by dry-run and Modal execution."""
45
+
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)
59
+ return payload
60
+
61
+
62
+ def load_sft_records(path: Path) -> list[dict[str, object]]:
63
+ """Load and validate chat-style SFT JSONL records."""
64
+
65
+ if not path.exists():
66
+ raise FileNotFoundError(f"Dataset path does not exist: {path}")
67
+
68
+ records: list[dict[str, object]] = []
69
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
70
+ if not line.strip():
71
+ continue
72
+ try:
73
+ raw = json.loads(line)
74
+ except json.JSONDecodeError as exc:
75
+ raise ValueError(f"Invalid JSON on line {line_number}: {exc.msg}") from exc
76
+ if not isinstance(raw, dict):
77
+ raise ValueError(f"Line {line_number} must be a JSON object.")
78
+ records.append(_validate_sft_record(raw, line_number))
79
+
80
+ if not records:
81
+ raise ValueError(f"Dataset has no records: {path}")
82
+ return records
83
+
84
+
85
+ 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
+
97
+ def run_training_entrypoint(
98
+ *,
99
+ dataset: Path,
100
+ config: TrainingConfig,
101
+ dry_run: bool,
102
+ allow_remote: bool,
103
+ remote_runner: Callable[[list[dict[str, object]], TrainingConfig], dict[str, object]] | None = None,
104
+ ) -> dict[str, object]:
105
+ """Validate inputs and either return a dry-run summary or launch Modal training."""
106
+
107
+ records = load_sft_records(dataset)
108
+ if dry_run:
109
+ return _dry_run_summary(dataset, records, config)
110
+
111
+ if not allow_remote:
112
+ raise RuntimeError("Use `modal run scripts/finetune_lora.py ...` for real training.")
113
+
114
+ runner = remote_runner or _run_modal_training
115
+ return runner(records, config)
116
+
117
+
118
+ def _validate_sft_record(raw: dict[str, object], line_number: int) -> dict[str, object]:
119
+ _validate_messages(raw.get("messages"), line_number=line_number)
120
+ return raw
121
+
122
+
123
+ def _validate_messages(raw_messages: object, line_number: int | None) -> list[dict[str, str]]:
124
+ location = f"line {line_number}" if line_number is not None else "record"
125
+ if not isinstance(raw_messages, list) or not raw_messages:
126
+ raise ValueError(f"{location} must include a non-empty messages list.")
127
+
128
+ messages: list[dict[str, str]] = []
129
+ for index, raw_message in enumerate(raw_messages, start=1):
130
+ if not isinstance(raw_message, dict):
131
+ raise ValueError(f"{location} message {index} must be an object.")
132
+ role = raw_message.get("role")
133
+ content = raw_message.get("content")
134
+ if not isinstance(role, str) or not role.strip():
135
+ raise ValueError(f"{location} message {index} must include a role.")
136
+ if not isinstance(content, str) or not content.strip():
137
+ raise ValueError(f"{location} message {index} must include content.")
138
+ messages.append({"role": role.strip(), "content": content.strip()})
139
+ return messages
140
+
141
+
142
+ def _dry_run_summary(
143
+ dataset: Path,
144
+ records: Sequence[Mapping[str, object]],
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,
160
+ "dropout": config.lora_dropout,
161
+ "target_modules": list(config.target_modules),
162
+ },
163
+ "first_training_text_chars": len(first_text),
164
+ "will_launch_modal": False,
165
+ }
166
+
167
+
168
+ def _run_modal_training(
169
+ records: list[dict[str, object]],
170
+ config: TrainingConfig,
171
+ ) -> dict[str, object]:
172
+ if modal is None:
173
+ raise RuntimeError("Modal is not installed. Install `requirements-training.txt` first.")
174
+ return train_lora_remote.remote(records, config.as_remote_dict())
175
+
176
+
177
+ def _train_lora_impl(
178
+ records: list[dict[str, object]],
179
+ config_payload: Mapping[str, object],
180
+ ) -> dict[str, object]:
181
+ from datasets import Dataset
182
+ import torch
183
+ from peft import LoraConfig, TaskType, get_peft_model
184
+ from transformers import (
185
+ AutoModelForCausalLM,
186
+ AutoTokenizer,
187
+ DataCollatorForLanguageModeling,
188
+ Trainer,
189
+ TrainingArguments,
190
+ )
191
+
192
+ config = _training_config_from_payload(config_payload)
193
+ output_path = Path(OUTPUT_DIR) / config.run_name
194
+ adapter_path = output_path / "adapter"
195
+ output_path.mkdir(parents=True, exist_ok=True)
196
+
197
+ tokenizer = AutoTokenizer.from_pretrained(config.base_model, trust_remote_code=True)
198
+ if tokenizer.pad_token is None:
199
+ tokenizer.pad_token = tokenizer.eos_token
200
+
201
+ model_kwargs: dict[str, object] = {"trust_remote_code": True}
202
+ if torch.cuda.is_available():
203
+ model_kwargs["torch_dtype"] = torch.float16
204
+
205
+ model = AutoModelForCausalLM.from_pretrained(config.base_model, **model_kwargs)
206
+ if hasattr(model, "config"):
207
+ model.config.use_cache = False
208
+
209
+ peft_config = LoraConfig(
210
+ r=config.lora_r,
211
+ lora_alpha=config.lora_alpha,
212
+ lora_dropout=config.lora_dropout,
213
+ target_modules=list(config.target_modules),
214
+ bias="none",
215
+ task_type=TaskType.CAUSAL_LM,
216
+ )
217
+ model = get_peft_model(model, peft_config)
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),
267
+ encoding="utf-8",
268
+ )
269
+ (output_path / "training_config.json").write_text(
270
+ json.dumps(config.as_remote_dict(), indent=2, sort_keys=True),
271
+ encoding="utf-8",
272
+ )
273
+
274
+ if _OUTPUT_VOLUME is not None:
275
+ _OUTPUT_VOLUME.commit()
276
+
277
+ return {
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)):
289
+ raise ValueError("target_modules must be a sequence of strings.")
290
+ return TrainingConfig(
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)),
299
+ target_modules=tuple(str(module) for module in target_modules),
300
+ )
301
+
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:
318
+ print(json.dumps(payload, indent=2, sort_keys=True), flush=True)
319
+
320
+
321
+ def _build_config_from_args(args: argparse.Namespace) -> TrainingConfig:
322
+ return 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
+
331
+ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
332
+ parser = argparse.ArgumentParser(description=__doc__)
333
+ parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET_PATH)
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
+
342
+
343
+ def _main(argv: Sequence[str] | None = None, *, allow_remote: bool = False) -> dict[str, object]:
344
+ args = _parse_args(argv)
345
+ result = run_training_entrypoint(
346
+ dataset=args.dataset,
347
+ config=_build_config_from_args(args),
348
+ dry_run=args.dry_run,
349
+ allow_remote=allow_remote,
350
+ )
351
+ _print_json(result)
352
+ return result
353
+
354
+
355
+ if modal is not None:
356
+ _IMAGE = (
357
+ modal.Image.debian_slim(python_version="3.10")
358
+ .uv_pip_install(
359
+ "torch",
360
+ "transformers>=4.40,<5",
361
+ "datasets",
362
+ "accelerate",
363
+ "peft",
364
+ "sentencepiece",
365
+ )
366
+ .env({"HF_HOME": CACHE_DIR})
367
+ )
368
+ _CACHE_VOLUME = modal.Volume.from_name("objectverse-diary-hf-cache", create_if_missing=True)
369
+ _OUTPUT_VOLUME = modal.Volume.from_name(
370
+ "objectverse-diary-lora-output",
371
+ create_if_missing=True,
372
+ )
373
+ app = modal.App(APP_NAME)
374
+
375
+ @app.function(
376
+ image=_IMAGE,
377
+ gpu="A10G",
378
+ timeout=2 * HOURS,
379
+ volumes={CACHE_DIR: _CACHE_VOLUME, OUTPUT_DIR: _OUTPUT_VOLUME},
380
+ )
381
+ def train_lora_remote(
382
+ records: list[dict[str, object]],
383
+ config_payload: dict[str, object],
384
+ ) -> dict[str, object]:
385
+ return _train_lora_impl(records, config_payload)
386
+
387
+ @app.local_entrypoint()
388
+ def modal_entrypoint(
389
+ dataset: str = str(DEFAULT_DATASET_PATH),
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(
398
+ dataset=Path(dataset),
399
+ config=TrainingConfig(
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,
408
+ )
409
+ _print_json(result)
410
+
411
+ else:
412
+ _OUTPUT_VOLUME = None
413
+ app = None
414
+
415
+ def train_lora_remote(
416
+ records: list[dict[str, object]],
417
+ config_payload: dict[str, object],
418
+ ) -> dict[str, object]:
419
+ raise RuntimeError("Modal is not installed. Install `requirements-training.txt` first.")
420
+
421
+
422
+ if __name__ == "__main__":
423
+ try:
424
+ _main(allow_remote=False)
425
+ except Exception as exc:
426
+ raise SystemExit(str(exc)) from exc
scripts/prepare_curated_dataset.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prepare synthetic curated SFT data for Objectverse Diary LoRA tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from pathlib import Path
10
+
11
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
12
+ if str(PROJECT_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(PROJECT_ROOT))
14
+
15
+ from src.models.schema import DiaryEntry, ObjectInfo, ObjectUnderstanding, Persona, PersonaEnvelope
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. "
24
+ "Given structured object understanding and a requested personality mode, "
25
+ "return strict JSON with keys persona and diary. Keep the voice strange, "
26
+ "specific to the object, and suitable for a shareable object archive."
27
+ )
28
+
29
+ MODES = ("Cynical", "Dramatic", "Lonely", "Philosopher", "Romantic")
30
+
31
+ OBJECTS = [
32
+ {
33
+ "name": "coffee mug",
34
+ "features": ["white ceramic", "coffee ring", "tiny handle shadow"],
35
+ "context": "developer desk",
36
+ "memory": "listened to morning promises dissolve into cold coffee",
37
+ },
38
+ {
39
+ "name": "mechanical keyboard",
40
+ "features": ["black keycaps", "dust in the rows", "one glossy spacebar"],
41
+ "context": "office corner",
42
+ "memory": "translated panic into clicking long after midnight",
43
+ },
44
+ {
45
+ "name": "running shoe",
46
+ "features": ["creased mesh", "mud on the sole", "loose lace"],
47
+ "context": "bedroom doorway",
48
+ "memory": "carried brave intentions to the end of the block and back",
49
+ },
50
+ {
51
+ "name": "desk lamp",
52
+ "features": ["brushed metal neck", "warm bulb", "tilted shade"],
53
+ "context": "late-night desk",
54
+ "memory": "held a circle of light over notes nobody finished",
55
+ },
56
+ {
57
+ "name": "water bottle",
58
+ "features": ["clear plastic wall", "scratched cap", "half-full body"],
59
+ "context": "kitchen counter",
60
+ "memory": "survived every resolution to drink more water",
61
+ },
62
+ {
63
+ "name": "notebook",
64
+ "features": ["bent corner", "blue ink ghosts", "elastic strap"],
65
+ "context": "bag pocket",
66
+ "memory": "guarded three plans, two lists, and one sentence crossed out hard",
67
+ },
68
+ {
69
+ "name": "umbrella",
70
+ "features": ["folded black canopy", "wet seam", "curved handle"],
71
+ "context": "entryway hook",
72
+ "memory": "became useful only when the sky was already theatrical",
73
+ },
74
+ {
75
+ "name": "house key",
76
+ "features": ["brass teeth", "scratched bow", "small metal ring"],
77
+ "context": "coat pocket",
78
+ "memory": "opened the same door for every version of its human",
79
+ },
80
+ {
81
+ "name": "charging cable",
82
+ "features": ["frayed sleeve", "white plastic tip", "gentle knot"],
83
+ "context": "bedside floor",
84
+ "memory": "fed glowing rectangles while pretending not to resent them",
85
+ },
86
+ {
87
+ "name": "teaspoon",
88
+ "features": ["silver bowl", "thin handle", "tea stain near the neck"],
89
+ "context": "sink edge",
90
+ "memory": "stirred sweetness into cups and suspicion into silence",
91
+ },
92
+ ]
93
+
94
+ MODE_PROFILES = {
95
+ "Cynical": {
96
+ "mood": "tired but sharply observant",
97
+ "fear": "being replaced by something newer and less honest",
98
+ "tag": ["dry witness", "domestic sarcasm", "small rebellion"],
99
+ "voice": "withholding applause",
100
+ },
101
+ "Dramatic": {
102
+ "mood": "grandly wounded",
103
+ "fear": "being forgotten before the curtain falls",
104
+ "tag": ["tragic prop", "household opera", "minor thunder"],
105
+ "voice": "making every scratch sound like fate",
106
+ },
107
+ "Lonely": {
108
+ "mood": "quietly abandoned",
109
+ "fear": "becoming background forever",
110
+ "tag": ["soft echo", "forgotten corner", "patient dust"],
111
+ "voice": "speaking as if the room almost listened",
112
+ },
113
+ "Philosopher": {
114
+ "mood": "curious and needlessly profound",
115
+ "fear": "discovering usefulness is not the same as meaning",
116
+ "tag": ["tiny ontology", "useful doubt", "object soul"],
117
+ "voice": "turning chores into metaphysics",
118
+ },
119
+ "Romantic": {
120
+ "mood": "hopelessly sentimental",
121
+ "fear": "loving a human who mistakes devotion for convenience",
122
+ "tag": ["tender witness", "secret devotion", "warm ache"],
123
+ "voice": "saving every ordinary touch as evidence",
124
+ },
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)
140
+ assistant_payload = {
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
+
172
+ def write_jsonl(records: Sequence[Mapping[str, object]], output_path: Path) -> Path:
173
+ output_path.parent.mkdir(parents=True, exist_ok=True)
174
+ lines = [json.dumps(record, ensure_ascii=False, sort_keys=True) for record in records]
175
+ output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
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:
184
+ return ObjectUnderstanding(
185
+ object=ObjectInfo(
186
+ name=str(obj["name"]),
187
+ visible_features=[str(feature) for feature in obj["features"]],
188
+ likely_context=str(obj["context"]),
189
+ confidence=0.9,
190
+ )
191
+ )
192
+
193
+
194
+ def _build_persona(obj: Mapping[str, object], mode: str) -> PersonaEnvelope:
195
+ profile = MODE_PROFILES[mode]
196
+ object_name = str(obj["name"])
197
+ character_name = _character_name(object_name, mode)
198
+ return PersonaEnvelope(
199
+ persona=Persona(
200
+ object_name=object_name,
201
+ character_name=character_name,
202
+ mood=str(profile["mood"]),
203
+ secret_fear=str(profile["fear"]),
204
+ core_memory=str(obj["memory"]),
205
+ complaint=f"I am not merely a {object_name}; I am an archive of what humans do when they think things cannot testify.",
206
+ tags=[str(tag) for tag in profile["tag"]],
207
+ )
208
+ )
209
+
210
+
211
+ def _build_diary(obj: Mapping[str, object], mode: str, persona: Persona, index: int) -> DiaryEntry:
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}",
230
+ english=english,
231
+ chinese=chinese,
232
+ )
233
+
234
+
235
+ def _character_name(object_name: str, mode: str) -> str:
236
+ compact = "".join(part.capitalize() for part in object_name.split()[:2])
237
+ suffix = {
238
+ "Cynical": "Ash",
239
+ "Dramatic": "of the Minor Stage",
240
+ "Lonely": "Afterlight",
241
+ "Philosopher": "the Questioning",
242
+ "Romantic": "de Moon",
243
+ }[mode]
244
+ return f"{compact} {suffix}".strip()
245
+
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:
253
+ payload = json.dumps(object_understanding, ensure_ascii=False, sort_keys=True)
254
+ return (
255
+ f"Personality mode: {mode}\n"
256
+ f"Object understanding JSON: {payload}\n"
257
+ "Return JSON with keys persona and diary only."
258
+ )
259
+
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__":
275
+ main()
scripts/publish_hf_adapter.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload a trained Objectverse Diary LoRA adapter folder 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
+ REQUIRED_ADAPTER_FILES = ("adapter_config.json",)
12
+ ADAPTER_WEIGHT_FILES = ("adapter_model.safetensors", "adapter_model.bin")
13
+
14
+
15
+ def validate_adapter_dir(adapter_dir: Path) -> dict[str, object]:
16
+ if not adapter_dir.exists() or not adapter_dir.is_dir():
17
+ raise FileNotFoundError(f"Adapter directory does not exist: {adapter_dir}")
18
+
19
+ missing = [name for name in REQUIRED_ADAPTER_FILES if not (adapter_dir / name).exists()]
20
+ has_weights = any((adapter_dir / name).exists() for name in ADAPTER_WEIGHT_FILES)
21
+ if not has_weights:
22
+ missing.append("adapter_model.safetensors or adapter_model.bin")
23
+ if missing:
24
+ raise ValueError(f"Adapter directory is missing required files: {', '.join(missing)}")
25
+
26
+ files = sorted(path.name for path in adapter_dir.iterdir() if path.is_file())
27
+ return {
28
+ "adapter_dir": str(adapter_dir),
29
+ "files": files,
30
+ "file_count": len(files),
31
+ }
32
+
33
+
34
+ def upload_adapter(
35
+ *,
36
+ adapter_dir: Path,
37
+ repo_id: str,
38
+ private: bool,
39
+ commit_message: str,
40
+ dry_run: bool,
41
+ ) -> dict[str, object]:
42
+ summary = validate_adapter_dir(adapter_dir)
43
+ summary.update(
44
+ {
45
+ "repo_id": repo_id,
46
+ "private": private,
47
+ "commit_message": commit_message,
48
+ "dry_run": dry_run,
49
+ }
50
+ )
51
+ if dry_run:
52
+ summary["uploaded"] = False
53
+ return summary
54
+
55
+ from huggingface_hub import HfApi
56
+
57
+ api = HfApi()
58
+ api.create_repo(repo_id=repo_id, repo_type="model", private=private, exist_ok=True)
59
+ api.upload_folder(
60
+ folder_path=str(adapter_dir),
61
+ repo_id=repo_id,
62
+ repo_type="model",
63
+ commit_message=commit_message,
64
+ )
65
+ summary["uploaded"] = True
66
+ summary["url"] = f"https://huggingface.co/{repo_id}"
67
+ return summary
68
+
69
+
70
+ def _print_json(payload: dict[str, Any]) -> None:
71
+ print(json.dumps(payload, indent=2, sort_keys=True), flush=True)
72
+
73
+
74
+ def _parse_args() -> argparse.Namespace:
75
+ parser = argparse.ArgumentParser(description=__doc__)
76
+ parser.add_argument("--adapter-dir", type=Path, required=True)
77
+ parser.add_argument("--repo-id", required=True)
78
+ parser.add_argument("--private", action="store_true")
79
+ parser.add_argument(
80
+ "--commit-message",
81
+ default="Upload Objectverse Diary LoRA adapter",
82
+ )
83
+ parser.add_argument("--dry-run", action="store_true")
84
+ return parser.parse_args()
85
+
86
+
87
+ def main() -> None:
88
+ args = _parse_args()
89
+ _print_json(
90
+ upload_adapter(
91
+ adapter_dir=args.adapter_dir,
92
+ repo_id=args.repo_id,
93
+ private=args.private,
94
+ commit_message=args.commit_message,
95
+ dry_run=args.dry_run,
96
+ )
97
+ )
98
+
99
+
100
+ if __name__ == "__main__":
101
+ try:
102
+ main()
103
+ except Exception as exc:
104
+ raise SystemExit(str(exc)) from exc
src/ui/layout.py CHANGED
@@ -66,116 +66,221 @@ GenerationUiResult = tuple[
66
  def build_app() -> gr.Blocks:
67
  css = Path("src/ui/styles.css").read_text(encoding="utf-8")
68
 
69
- with gr.Blocks(head=f"<style>{css}</style>", title=APP_TITLE, fill_width=True) as demo:
70
- gr.HTML(
71
- f"""
72
- <section id="objectverse-hero">
73
- <div class="hero-mark">
74
- <span>OVD</span>
75
- <small>000827</small>
76
- </div>
77
- <div class="hero-copy">
78
- <p class="hero-kicker">Local small-model object archive<br><span>本地小模型物品档案</span></p>
79
- <h1>{APP_TITLE}</h1>
80
- <p>Every object has a secret life.<br><span>每个物品都有秘密人生。</span></p>
81
- </div>
82
- <div class="hero-badges" aria-label="Project constraints">
83
- <span>Small Models</span>
84
- <span>Local-First</span>
85
- <span>No Cloud APIs</span>
86
- </div>
87
- </section>
88
- """,
89
- padding=False,
90
- )
91
 
92
- result_state = gr.State()
93
- zero_gpu_probe_button = gr.Button(visible=False)
94
- zero_gpu_probe_output = gr.JSON(visible=False)
95
-
96
- with gr.Row(elem_id="archive-main-grid", elem_classes=["archive-grid"]):
97
- with gr.Column(scale=4, elem_classes=["archive-panel", "intake-panel"]):
98
- gr.HTML(_panel_header("01", "Object Intake", "物品接收", "Upload, describe, or pick a sample."), padding=False)
99
- image_input = gr.Image(
100
- label=copy.UPLOAD_LABEL,
101
- show_label=False,
102
- type="filepath",
103
- sources=["upload"],
104
- elem_id="object-upload",
105
- )
106
- description_input = gr.Textbox(
107
- label=copy.DESCRIPTION_LABEL,
108
- placeholder=copy.DESCRIPTION_PLACEHOLDER,
109
- lines=3,
110
- max_lines=5,
111
- elem_id="object-description",
112
- )
113
- mode_input = gr.Radio(
114
- label=copy.MODE_LABEL,
115
- choices=PERSONALITY_MODES,
116
- value=DEFAULT_MODE,
117
- elem_id="personality-mode",
118
- elem_classes=["mode-switch"],
 
 
 
 
 
 
 
 
119
  )
120
- generate_button = gr.Button(copy.GENERATE_LABEL, variant="primary", elem_id="wake-button")
121
 
 
 
122
  gr.HTML(
123
- """
124
- <div class="example-section-title">
125
- <span>Example Objects / 示例物品</span>
126
- <small>Click a file to generate instantly.</small>
127
- </div>
 
 
 
 
 
 
 
128
  """,
129
  padding=False,
130
  )
131
- example_buttons: list[gr.Button] = []
132
- for index in range(len(EXAMPLE_OBJECTS)):
133
- example_buttons.append(
134
- gr.Button(
135
- example_button_label(index),
136
- elem_classes=["example-card"],
137
- variant="secondary",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  )
139
- )
140
-
141
- with gr.Column(scale=4, elem_classes=["archive-panel", "file-panel"]):
142
- gr.HTML(_panel_header("02", "Object File", "物品档案", "Structured mock understanding and persona."), padding=False)
143
- object_file_summary = gr.HTML(value=OBJECT_FILE_EMPTY, elem_id="object-file-summary", padding=False)
144
- with gr.Accordion("Raw object understanding JSON / 原始物品识别 JSON", open=False):
145
- object_json = gr.JSON(value={}, label=copy.OBJECT_JSON_LABEL)
146
- with gr.Accordion("Raw persona JSON / 原始人格 JSON", open=False):
147
- persona_json = gr.JSON(value={}, label=copy.PERSONA_JSON_LABEL)
148
-
149
- with gr.Column(scale=4, elem_classes=["archive-panel", "diary-panel"]):
150
- gr.HTML(_panel_header("03", "Secret Diary", "秘密日记", "A private note written by the object."), padding=False)
151
- diary_output = gr.Markdown(
152
- value=DIARY_EMPTY,
153
- label=copy.DIARY_LABEL,
154
- elem_id="diary-output",
155
- )
156
 
157
- with gr.Row(elem_id="archive-bottom-grid", elem_classes=["archive-grid", "bottom-grid"]):
158
- with gr.Column(scale=5, elem_classes=["archive-panel", "share-panel"]):
159
- gr.HTML(_panel_header("04", "Share Card", "分享卡片", "Fixed-width card for screenshots."), padding=False)
160
- share_card = gr.HTML(value=SHARE_CARD_EMPTY, label=copy.SHARE_CARD_LABEL, padding=False)
161
-
162
- with gr.Column(scale=4, elem_classes=["archive-panel", "chat-panel"]):
163
- gr.HTML(_panel_header("05", "Object Chat", "物品对话", "Ask after the object wakes up."), padding=False)
164
- chatbot = gr.Chatbot(
165
- value=_empty_chat_history(),
166
- label=copy.CHAT_LABEL,
167
- type="messages",
168
- height=300,
169
- allow_tags=False,
170
- )
171
- chat_input = gr.Textbox(placeholder=copy.CHAT_INPUT_PLACEHOLDER, show_label=False)
172
- chat_button = gr.Button(copy.CHAT_BUTTON_LABEL, elem_classes=["quiet-button"])
173
-
174
- with gr.Column(scale=3, elem_classes=["archive-panel", "trace-panel"]):
175
- gr.HTML(_panel_header("06", "Trace", "模型轨迹", "Saved JSON record for reproducibility."), padding=False)
176
- trace_summary = gr.HTML(value=TRACE_EMPTY, elem_id="trace-summary", padding=False)
177
- trace_json = gr.JSON(value={}, label=copy.TRACE_JSON_LABEL)
178
- trace_path = gr.Textbox(label=copy.TRACE_PATH_LABEL, interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
  manual_outputs = [
181
  object_file_summary,
 
66
  def build_app() -> gr.Blocks:
67
  css = Path("src/ui/styles.css").read_text(encoding="utf-8")
68
 
69
+ custom_theme = gr.themes.Monochrome(
70
+ primary_hue="amber",
71
+ secondary_hue="yellow",
72
+ neutral_hue="stone",
73
+ ).set(
74
+ body_background_fill="#161513",
75
+ body_background_fill_dark="#161513",
76
+ background_fill_primary="#161513",
77
+ background_fill_primary_dark="#161513",
78
+ background_fill_secondary="rgba(30, 28, 25, 0.6)",
79
+ background_fill_secondary_dark="rgba(30, 28, 25, 0.6)",
80
+ border_color_primary="rgba(212, 175, 55, 0.15)",
81
+ border_color_primary_dark="rgba(212, 175, 55, 0.15)",
82
+ block_background_fill="transparent",
83
+ block_background_fill_dark="transparent",
84
+ block_border_width="0px",
85
+ panel_background_fill="transparent",
86
+ panel_background_fill_dark="transparent",
87
+ )
 
 
 
88
 
89
+ with gr.Blocks(theme=custom_theme, head=f"<style>{css}</style>", title=APP_TITLE, fill_width=True, elem_id="objectverse-app") as demo:
90
+ with gr.Row(elem_id="app-container"):
91
+ # === Sidebar ===
92
+ with gr.Column(elem_id="sidebar", scale=0, min_width=240):
93
+ gr.HTML(
94
+ """
95
+ <nav class="sidebar-nav">
96
+ <div class="sidebar-logo">
97
+ <div class="logo-icon"></div>
98
+ <h2>Objectverse<br>Diary</h2>
99
+ </div>
100
+ <ul class="sidebar-menu">
101
+ <li class="active"><a href="#intake">Home</a></li>
102
+ <li><a href="#intake">Intake</a></li>
103
+ <li><a href="#object-file">Object File</a></li>
104
+ <li><a href="#diary">Diary</a></li>
105
+ <li><a href="#chat-panel">Chat</a></li>
106
+ <li><a href="#share-panel">Share Card</a></li>
107
+ <li><a href="#trace">Trace</a></li>
108
+ <li><a href="#settings">Settings</a></li>
109
+ </ul>
110
+ <div class="sidebar-footer">
111
+ <div class="footer-stamp">
112
+ <small>OBJECTVERSE ARCHIVE</small>
113
+ <span>No. 000827</span>
114
+ <small>Curate. Converse. Cherish.</small>
115
+ </div>
116
+ <div class="lang-switch">
117
+ <button class="active">EN</button>
118
+ <button>中文</button>
119
+ </div>
120
+ </div>
121
+ </nav>
122
+ """,
123
+ padding=False,
124
  )
 
125
 
126
+ # === Main Content Area ===
127
+ with gr.Column(elem_id="main-content", scale=1):
128
  gr.HTML(
129
+ f"""
130
+ <section id="objectverse-hero">
131
+ <div class="hero-copy">
132
+ <h1>{APP_TITLE}</h1>
133
+ <p class="hero-kicker">Every object has a secret life.<br><span>万物日记:每个物品都有秘密人生</span></p>
134
+ </div>
135
+ <div class="hero-badges" aria-label="Project constraints">
136
+ <span>Small Models</span>
137
+ <span>Local-First</span>
138
+ <span>No Cloud APIs</span>
139
+ </div>
140
+ </section>
141
  """,
142
  padding=False,
143
  )
144
+
145
+ result_state = gr.State()
146
+ zero_gpu_probe_button = gr.Button(visible=False)
147
+ zero_gpu_probe_output = gr.JSON(visible=False)
148
+
149
+ # Intake & Examples Row
150
+ with gr.Row(elem_id="intake", elem_classes=["content-section"]):
151
+ # Left: Intake
152
+ with gr.Column(scale=7, elem_classes=["archive-panel", "intake-panel"]):
153
+ image_input = gr.Image(
154
+ label=copy.UPLOAD_LABEL,
155
+ show_label=False,
156
+ type="filepath",
157
+ sources=["upload"],
158
+ elem_id="object-upload",
159
+ )
160
+ gr.HTML("""<div class="or-divider"><span>OR</span></div>""", padding=False)
161
+ description_input = gr.Textbox(
162
+ label=copy.DESCRIPTION_LABEL,
163
+ placeholder=copy.DESCRIPTION_PLACEHOLDER,
164
+ lines=2,
165
+ max_lines=5,
166
+ elem_id="object-description",
167
+ )
168
+
169
+ gr.HTML("""<div class="mode-header">Personality mode <small>人格模式</small> <span class="help-icon">?</span></div>""", padding=False)
170
+ mode_input = gr.Radio(
171
+ label=copy.MODE_LABEL,
172
+ show_label=False,
173
+ choices=PERSONALITY_MODES,
174
+ value=DEFAULT_MODE,
175
+ elem_id="personality-mode",
176
+ elem_classes=["mode-switch"],
177
+ )
178
+ generate_button = gr.Button("Wake the Object\n唤醒物品", variant="primary", elem_id="wake-button")
179
+
180
+ gr.HTML(
181
+ """
182
+ <div class="how-it-works">
183
+ <div class="step">
184
+ <span class="step-num">01</span>
185
+ <div class="step-icon img-icon"></div>
186
+ <div class="step-text">
187
+ <strong>Upload or describe</strong>
188
+ <small>上传物品或描述心情</small>
189
+ <p>Give me a photo or words—anything that holds a story.</p>
190
+ </div>
191
+ </div>
192
+ <div class="step">
193
+ <span class="step-num">02</span>
194
+ <div class="step-icon pen-icon"></div>
195
+ <div class="step-text">
196
+ <strong>I imagine its life</strong>
197
+ <small>我为它编织人生</small>
198
+ <p>I'll step into its shoes and imagine its secret life.</p>
199
+ </div>
200
+ </div>
201
+ <div class="step">
202
+ <span class="step-num">03</span>
203
+ <div class="step-icon book-icon"></div>
204
+ <div class="step-text">
205
+ <strong>Read its diary</strong>
206
+ <small>阅读物品日记</small>
207
+ <p>Receive a diary entry written from its perspective.</p>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ """,
212
+ padding=False,
213
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
+ # Right: Examples
216
+ with gr.Column(scale=4, elem_classes=["archive-panel", "examples-panel"]):
217
+ gr.HTML(
218
+ """
219
+ <div class="example-header">
220
+ <div class="books-icon"></div>
221
+ <div>
222
+ <strong>Example Objects</strong>
223
+ <span>灵感库</span>
224
+ </div>
225
+ </div>
226
+ """,
227
+ padding=False,
228
+ )
229
+ example_buttons: list[gr.Button] = []
230
+ for index in range(len(EXAMPLE_OBJECTS)):
231
+ example_buttons.append(
232
+ gr.Button(
233
+ example_button_label(index),
234
+ elem_classes=["example-card"],
235
+ variant="secondary",
236
+ )
237
+ )
238
+ gr.HTML("""<a href="#object-file" class="view-more">View more in Object File →</a>""", padding=False)
239
+
240
+ # Object File Section
241
+ with gr.Row(elem_id="object-file", elem_classes=["content-section"]):
242
+ with gr.Column(scale=1, elem_classes=["archive-panel", "file-panel"]):
243
+ gr.HTML(_panel_header("02", "Object File / Recognition", "物品档案", "Structured mock understanding and persona."), padding=False)
244
+ object_file_summary = gr.HTML(value=OBJECT_FILE_EMPTY, elem_id="object-file-summary", padding=False)
245
+ with gr.Accordion("Raw JSON", open=False):
246
+ object_json = gr.JSON(value={}, label=copy.OBJECT_JSON_LABEL)
247
+ persona_json = gr.JSON(value={}, label=copy.PERSONA_JSON_LABEL)
248
+
249
+ # Diary Section
250
+ with gr.Row(elem_id="diary", elem_classes=["content-section"]):
251
+ with gr.Column(scale=1, elem_classes=["archive-panel", "diary-panel"]):
252
+ gr.HTML(_panel_header("03", "Secret Diary", "秘密日记", "A private note written by the object."), padding=False)
253
+ diary_output = gr.Markdown(
254
+ value=DIARY_EMPTY,
255
+ label=copy.DIARY_LABEL,
256
+ elem_id="diary-output",
257
+ )
258
+
259
+ # Share & Chat Section
260
+ with gr.Row(elem_id="share", elem_classes=["content-section", "split-section"]):
261
+ with gr.Column(scale=5, elem_classes=["archive-panel", "share-panel", "anchored"], elem_id="share-panel"):
262
+ gr.HTML(_panel_header("04", "Share Card", "分享卡片", "Fixed-width card for screenshots."), padding=False)
263
+ share_card = gr.HTML(value=SHARE_CARD_EMPTY, label=copy.SHARE_CARD_LABEL, padding=False)
264
+
265
+ with gr.Column(scale=4, elem_classes=["archive-panel", "chat-panel", "anchored"], elem_id="chat-panel"):
266
+ gr.HTML(_panel_header("05", "Object Chat", "物品对话", "Ask after the object wakes up."), padding=False)
267
+ chatbot = gr.Chatbot(
268
+ value=_empty_chat_history(),
269
+ label=copy.CHAT_LABEL,
270
+ type="messages",
271
+ height=300,
272
+ allow_tags=False,
273
+ )
274
+ chat_input = gr.Textbox(placeholder=copy.CHAT_INPUT_PLACEHOLDER, show_label=False)
275
+ chat_button = gr.Button(copy.CHAT_BUTTON_LABEL, elem_classes=["quiet-button"])
276
+
277
+ # Trace Section
278
+ with gr.Row(elem_id="trace", elem_classes=["content-section"]):
279
+ with gr.Column(scale=1, elem_classes=["archive-panel", "trace-panel"]):
280
+ gr.HTML(_panel_header("06", "Trace", "模型轨迹", "Saved JSON record for reproducibility."), padding=False)
281
+ trace_summary = gr.HTML(value=TRACE_EMPTY, elem_id="trace-summary", padding=False)
282
+ trace_json = gr.JSON(value={}, label=copy.TRACE_JSON_LABEL)
283
+ trace_path = gr.Textbox(label=copy.TRACE_PATH_LABEL, interactive=False)
284
 
285
  manual_outputs = [
286
  object_file_summary,
src/ui/styles.css CHANGED
@@ -1,733 +1,618 @@
1
- :root {
2
- --ov-bg: #15120e;
3
- --ov-panel: rgba(31, 27, 21, 0.9);
4
- --ov-panel-soft: rgba(44, 37, 28, 0.72);
5
- --ov-border: rgba(214, 165, 82, 0.34);
6
- --ov-border-strong: rgba(232, 176, 82, 0.58);
7
- --ov-text: #f0e4d0;
8
- --ov-muted: #c4ad8d;
9
- --ov-faint: #8f7b5f;
10
- --ov-amber: #d99a35;
11
- --ov-amber-bright: #f0bd62;
12
- --ov-green: #9fb37a;
13
- --ov-red: #b96f55;
14
- --ov-shadow: 0 18px 50px rgba(0, 0, 0, 0.38);
15
- }
16
-
17
- html,
18
- body,
19
- gradio-app {
20
- background: var(--ov-bg);
21
- overflow-x: hidden;
22
- width: 100%;
23
- }
24
-
25
- body {
26
- margin: 0;
27
- }
28
-
29
- .gradio-container,
30
- .gradio-container * {
31
- box-sizing: border-box;
32
- min-width: 0;
33
- }
34
-
35
- .gradio-container {
36
- background:
37
- linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px),
38
- linear-gradient(90deg, rgba(255, 255, 255, 0.014) 1px, transparent 1px),
39
- linear-gradient(135deg, #18140f 0%, #241d16 45%, #11100e 100%);
40
- background-size: 28px 28px, 28px 28px, auto;
41
- color: var(--ov-text);
42
- font-family: Georgia, "Times New Roman", serif;
43
- margin: 0 auto !important;
44
- max-width: 1480px !important;
45
- min-height: 100vh;
46
- overflow-x: hidden;
47
- padding: 24px !important;
48
- }
49
-
50
- .gradio-container > main,
51
- .gradio-container > main > .wrap,
52
- .gradio-container > main > .wrap > .contain {
53
- margin-left: 0 !important;
54
- margin-right: 0 !important;
55
- max-width: 100% !important;
56
- padding-left: 0 !important;
57
- padding-right: 0 !important;
58
- width: 100% !important;
59
- }
60
-
61
- .gradio-container .contain {
62
- max-width: none !important;
63
- }
64
-
65
- #objectverse-hero {
66
- align-items: center;
67
- background:
68
- linear-gradient(90deg, rgba(217, 154, 53, 0.08), transparent 34%),
69
- rgba(23, 20, 16, 0.86);
70
- border: 1px solid var(--ov-border);
71
- border-radius: 8px;
72
- box-shadow: var(--ov-shadow);
73
- display: grid;
74
- gap: 18px;
75
- grid-template-columns: auto 1fr auto;
76
- padding: 18px;
77
- }
78
-
79
- .hero-mark {
80
- align-items: center;
81
- border: 1px solid var(--ov-border-strong);
82
- border-radius: 50%;
83
- color: var(--ov-amber-bright);
84
- display: flex;
85
- flex-direction: column;
86
- height: 82px;
87
- justify-content: center;
88
- width: 82px;
89
- }
90
-
91
- .hero-mark span,
92
- .hero-mark small {
93
- letter-spacing: 0;
94
- }
95
-
96
- .hero-mark span {
97
- font-size: 20px;
98
- }
99
-
100
- .hero-mark small {
101
- color: var(--ov-muted);
102
- font-size: 11px;
103
- }
104
-
105
- .hero-kicker {
106
- color: var(--ov-amber-bright);
107
- font-size: 13px;
108
- font-style: italic;
109
- margin: 0 0 8px;
110
- overflow-wrap: anywhere;
111
- white-space: normal;
112
- }
113
-
114
- #objectverse-hero h1 {
115
- color: var(--ov-text);
116
- font-size: 40px;
117
- line-height: 1.05;
118
- margin: 0 0 8px;
119
- }
120
-
121
- #objectverse-hero p {
122
- color: var(--ov-muted);
123
- font-size: 16px;
124
- line-height: 1.5;
125
- margin: 0;
126
- overflow-wrap: anywhere;
127
- white-space: normal;
128
- }
129
-
130
- #objectverse-hero .hero-copy span {
131
- color: var(--ov-muted);
132
- }
133
-
134
- .hero-badges {
135
- display: flex;
136
- flex-wrap: wrap;
137
- gap: 10px;
138
- justify-content: flex-end;
139
- }
140
-
141
- #objectverse-hero .hero-badges span {
142
- border: 1px solid var(--ov-border);
143
- border-radius: 6px;
144
- color: var(--ov-amber-bright);
145
- font-size: 13px;
146
- padding: 10px 14px;
147
- white-space: nowrap;
148
- }
149
-
150
- #objectverse-hero .hero-mark span {
151
- color: var(--ov-amber-bright);
152
- }
153
-
154
- #archive-main-grid,
155
- #archive-bottom-grid {
156
- gap: 16px;
157
- margin-top: 16px;
158
- }
159
-
160
- .archive-panel {
161
- background:
162
- linear-gradient(rgba(255, 255, 255, 0.025), transparent),
163
- var(--ov-panel);
164
- border: 1px solid var(--ov-border);
165
- border-radius: 8px;
166
- box-shadow: var(--ov-shadow);
167
- padding: 16px;
168
- }
169
-
170
- .archive-panel .block,
171
- .archive-panel .form,
172
- .archive-panel .wrap,
173
- .archive-panel .input-container,
174
- .archive-panel textarea,
175
- .archive-panel input {
176
- background-color: transparent !important;
177
- color: var(--ov-text) !important;
178
- }
179
-
180
- .panel-header {
181
- align-items: flex-start;
182
- border-bottom: 1px solid rgba(214, 165, 82, 0.18);
183
- display: flex;
184
- gap: 12px;
185
- margin-bottom: 16px;
186
- padding-bottom: 12px;
187
- }
188
-
189
- .panel-header > span {
190
- background: rgba(217, 154, 53, 0.16);
191
- border: 1px solid var(--ov-border);
192
- border-radius: 6px;
193
- color: var(--ov-amber-bright) !important;
194
- display: inline-flex;
195
- flex: 0 0 auto;
196
- font-size: 13px;
197
- justify-content: center;
198
- padding: 7px 9px;
199
- }
200
-
201
- .panel-header h2 {
202
- color: var(--ov-text) !important;
203
- font-size: 19px;
204
- line-height: 1.2;
205
- margin: 0;
206
- }
207
-
208
- .panel-header small {
209
- color: var(--ov-muted) !important;
210
- font-size: 13px;
211
- font-weight: 400;
212
- }
213
-
214
- .panel-header p {
215
- color: var(--ov-muted) !important;
216
- font-size: 13px;
217
- line-height: 1.45;
218
- margin: 5px 0 0;
219
- }
220
-
221
- .gradio-container label,
222
- .gradio-container .label-wrap span {
223
- color: var(--ov-muted) !important;
224
- }
225
-
226
- .gradio-container textarea,
227
- .gradio-container input[type="text"] {
228
- border: 1px solid rgba(214, 165, 82, 0.24) !important;
229
- border-radius: 6px !important;
230
- color: var(--ov-text) !important;
231
- font-family: Georgia, "Times New Roman", serif !important;
232
- line-height: 1.5 !important;
233
- overflow-wrap: break-word !important;
234
- white-space: pre-wrap !important;
235
- }
236
-
237
- .gradio-container textarea::placeholder,
238
- .gradio-container input::placeholder {
239
- color: rgba(196, 173, 141, 0.68) !important;
240
- }
241
-
242
- #object-upload {
243
- border: 1px dashed rgba(217, 154, 53, 0.52);
244
- border-radius: 8px;
245
- overflow: hidden;
246
- }
247
-
248
- #personality-mode .wrap,
249
- .mode-switch .wrap {
250
- display: flex !important;
251
- flex-wrap: wrap !important;
252
- gap: 8px !important;
253
- }
254
-
255
- #personality-mode label,
256
- .mode-switch label {
257
- align-items: center;
258
- background: rgba(31, 27, 21, 0.92) !important;
259
- border: 1px solid rgba(214, 165, 82, 0.34) !important;
260
- border-radius: 7px !important;
261
- color: var(--ov-muted) !important;
262
- display: flex !important;
263
- flex: 1 1 102px;
264
- justify-content: center;
265
- min-height: 48px;
266
- padding: 8px !important;
267
- text-align: center;
268
- white-space: normal !important;
269
- }
270
-
271
- #personality-mode label span,
272
- .mode-switch label span {
273
- color: var(--ov-muted) !important;
274
- overflow-wrap: anywhere;
275
- white-space: normal !important;
276
- }
277
-
278
- #personality-mode label:has(input:checked),
279
- .mode-switch label:has(input:checked) {
280
- background: rgba(217, 154, 53, 0.18) !important;
281
- border-color: var(--ov-amber) !important;
282
- color: var(--ov-amber-bright) !important;
283
- }
284
-
285
- #personality-mode label:has(input:checked) span,
286
- .mode-switch label:has(input:checked) span {
287
- color: var(--ov-amber-bright) !important;
288
- }
289
-
290
- #wake-button {
291
- background: linear-gradient(180deg, #e0ad62 0%, #bd7926 100%) !important;
292
- border: 1px solid #f0bd62 !important;
293
- border-radius: 8px !important;
294
- color: #1d140b !important;
295
- font-family: Georgia, "Times New Roman", serif !important;
296
- font-size: 18px !important;
297
- font-weight: 700 !important;
298
- min-height: 58px;
299
- text-shadow: none !important;
300
- }
301
-
302
- .quiet-button {
303
- border: 1px solid var(--ov-border) !important;
304
- color: var(--ov-amber-bright) !important;
305
- }
306
-
307
- .example-section-title {
308
- align-items: baseline;
309
- border-top: 1px solid rgba(214, 165, 82, 0.18);
310
- display: flex;
311
- gap: 10px;
312
- justify-content: space-between;
313
- margin: 18px 0 10px;
314
- padding-top: 14px;
315
- }
316
-
317
- .example-section-title span {
318
- color: var(--ov-text);
319
- font-size: 15px;
320
- }
321
-
322
- .example-section-title small {
323
- color: var(--ov-faint);
324
- font-size: 12px;
325
- }
326
-
327
- button.example-card {
328
- background:
329
- linear-gradient(90deg, rgba(217, 154, 53, 0.1), transparent 52%),
330
- var(--ov-panel-soft) !important;
331
- border: 1px solid rgba(214, 165, 82, 0.26) !important;
332
- border-radius: 7px !important;
333
- color: var(--ov-text) !important;
334
- display: block !important;
335
- font-family: Georgia, "Times New Roman", serif !important;
336
- height: auto !important;
337
- line-height: 1.4 !important;
338
- margin-top: 8px !important;
339
- min-height: 78px;
340
- overflow-wrap: anywhere;
341
- padding: 12px !important;
342
- text-align: left !important;
343
- white-space: pre-wrap !important;
344
- width: 100%;
345
- }
346
-
347
- button.example-card:hover,
348
- .quiet-button:hover {
349
- border-color: var(--ov-amber) !important;
350
- color: var(--ov-amber-bright) !important;
351
- }
352
-
353
- .archive-empty,
354
- .objectverse-placeholder,
355
- .archive-error {
356
- border: 1px dashed rgba(214, 165, 82, 0.3);
357
- border-radius: 8px;
358
- color: var(--ov-muted) !important;
359
- line-height: 1.55;
360
- padding: 18px;
361
- }
362
-
363
- .archive-empty h3,
364
- .objectverse-placeholder strong,
365
- .archive-error strong {
366
- color: var(--ov-text) !important;
367
- display: block;
368
- font-size: 20px;
369
- margin: 8px 0;
370
- }
371
-
372
- .archive-empty.compact,
373
- .trace-card {
374
- padding: 14px;
375
- }
376
-
377
- .archive-label,
378
- .objectverse-placeholder span,
379
- .archive-error span {
380
- color: var(--ov-amber-bright) !important;
381
- display: block;
382
- font-size: 12px;
383
- text-transform: uppercase;
384
- }
385
-
386
- .archive-error {
387
- border-color: rgba(185, 111, 85, 0.72);
388
- }
389
-
390
- .archive-error span,
391
- .archive-error strong {
392
- color: #f3a184 !important;
393
- }
394
-
395
- .archive-empty p,
396
- .objectverse-placeholder p,
397
- .archive-error p {
398
- color: var(--ov-muted) !important;
399
- }
400
-
401
- .object-file-card,
402
- .trace-card {
403
- background: rgba(18, 16, 13, 0.52);
404
- border: 1px solid rgba(214, 165, 82, 0.24);
405
- border-radius: 8px;
406
- padding: 18px;
407
- }
408
-
409
- .file-meta {
410
- display: flex;
411
- flex-wrap: wrap;
412
- gap: 8px;
413
- margin-bottom: 12px;
414
- }
415
-
416
- .file-meta span,
417
- .file-tags span,
418
- .card-tags span {
419
- border: 1px solid rgba(214, 165, 82, 0.28);
420
- border-radius: 999px;
421
- color: var(--ov-amber-bright);
422
- display: inline-flex;
423
- font-size: 12px;
424
- line-height: 1;
425
- padding: 7px 9px;
426
- }
427
-
428
- .object-file-card h3 {
429
- color: var(--ov-text);
430
- font-size: 28px;
431
- line-height: 1.12;
432
- margin: 0 0 8px;
433
- }
434
-
435
- .object-name {
436
- color: var(--ov-muted);
437
- margin: 0 0 16px;
438
- }
439
-
440
- .object-file-card dl {
441
- display: grid;
442
- gap: 10px;
443
- margin: 0 0 16px;
444
- }
445
-
446
- .object-file-card dl > div {
447
- border-top: 1px solid rgba(214, 165, 82, 0.14);
448
- padding-top: 10px;
449
- }
450
-
451
- .object-file-card dt {
452
- color: var(--ov-faint);
453
- font-size: 12px;
454
- margin-bottom: 3px;
455
- text-transform: uppercase;
456
- }
457
-
458
- .object-file-card dd {
459
- color: var(--ov-text);
460
- line-height: 1.45;
461
- margin: 0;
462
- }
463
-
464
- .feature-list {
465
- border: 1px solid rgba(159, 179, 122, 0.25);
466
- border-radius: 7px;
467
- margin-bottom: 16px;
468
- padding: 12px 14px;
469
- }
470
-
471
- .feature-list strong {
472
- color: var(--ov-green);
473
- }
474
-
475
- .feature-list ul {
476
- color: var(--ov-muted);
477
- margin: 8px 0 0;
478
- padding-left: 18px;
479
- }
480
-
481
- .complaint {
482
- border-left: 3px solid var(--ov-red);
483
- color: var(--ov-text);
484
- font-style: italic;
485
- line-height: 1.55;
486
- margin: 0 0 14px;
487
- padding-left: 12px;
488
- }
489
-
490
- .file-tags,
491
- .card-tags {
492
- display: flex;
493
- flex-wrap: wrap;
494
- gap: 8px;
495
- }
496
-
497
- #diary-output,
498
- #diary-output * {
499
- color: var(--ov-muted) !important;
500
- }
501
-
502
- #diary-output {
503
- background: rgba(18, 16, 13, 0.5);
504
- border: 1px solid rgba(214, 165, 82, 0.22);
505
- border-radius: 8px;
506
- min-height: 320px;
507
- padding: 18px !important;
508
- }
509
-
510
- #diary-output h1,
511
- #diary-output h2,
512
- #diary-output h3 {
513
- color: var(--ov-amber-bright) !important;
514
- }
515
-
516
- #diary-output p {
517
- font-size: 16px;
518
- line-height: 1.7;
519
- }
520
-
521
- .objectverse-card {
522
- background:
523
- linear-gradient(180deg, rgba(255, 245, 218, 0.06), rgba(34, 24, 14, 0.1)),
524
- #241b12;
525
- border: 1px solid rgba(240, 189, 98, 0.58);
526
- border-radius: 8px;
527
- box-shadow: 0 22px 55px rgba(0, 0, 0, 0.45);
528
- color: var(--ov-text);
529
- max-width: 560px;
530
- padding: 24px;
531
- width: 100%;
532
- }
533
-
534
- .card-header {
535
- align-items: flex-start;
536
- display: flex;
537
- gap: 12px;
538
- justify-content: space-between;
539
- }
540
-
541
- .objectverse-card h2 {
542
- color: var(--ov-text);
543
- font-size: 32px;
544
- line-height: 1.08;
545
- margin: 8px 0;
546
- }
547
-
548
- .card-kicker,
549
- .card-object,
550
- .card-cn {
551
- color: var(--ov-muted);
552
- letter-spacing: 0;
553
- }
554
-
555
- .card-kicker {
556
- font-size: 12px;
557
- text-transform: uppercase;
558
- }
559
-
560
- .card-stamp {
561
- border: 1px solid rgba(217, 154, 53, 0.42);
562
- border-radius: 50%;
563
- color: var(--ov-amber-bright);
564
- flex: 0 0 auto;
565
- font-size: 11px;
566
- height: 64px;
567
- padding-top: 24px;
568
- text-align: center;
569
- width: 64px;
570
- }
571
-
572
- .card-quote {
573
- border-left: 3px solid var(--ov-amber);
574
- color: var(--ov-text);
575
- font-size: 18px;
576
- line-height: 1.62;
577
- margin: 20px 0 14px;
578
- padding-left: 14px;
579
- }
580
-
581
- .card-cn {
582
- font-size: 14px;
583
- line-height: 1.6;
584
- margin: 0 0 18px;
585
- }
586
-
587
- .trace-card strong {
588
- color: var(--ov-text);
589
- display: block;
590
- margin: 8px 0;
591
- overflow-wrap: anywhere;
592
- }
593
-
594
- .trace-card p {
595
- color: var(--ov-muted);
596
- line-height: 1.5;
597
- margin: 0;
598
- }
599
-
600
- .gradio-container .json-holder,
601
- .gradio-container pre {
602
- max-width: 100%;
603
- overflow: auto !important;
604
- }
605
-
606
- @media (max-width: 980px) {
607
- gradio-app,
608
- .gradio-container,
609
- .gradio-container .main,
610
- .gradio-container .contain {
611
- margin: 0 !important;
612
- max-width: 100vw !important;
613
- overflow-x: hidden !important;
614
- width: 100vw !important;
615
- }
616
-
617
- .gradio-container {
618
- padding: 14px !important;
619
- }
620
-
621
- .gradio-container > main,
622
- .gradio-container > main > .wrap,
623
- .gradio-container > main > .wrap > .contain {
624
- max-width: 100% !important;
625
- overflow-x: hidden !important;
626
- padding-left: 0 !important;
627
- padding-right: 0 !important;
628
- width: 100% !important;
629
- }
630
-
631
- #objectverse-hero {
632
- grid-template-columns: 1fr;
633
- max-width: calc(100vw - 28px);
634
- width: calc(100vw - 28px);
635
- }
636
-
637
- #archive-main-grid,
638
- #archive-bottom-grid {
639
- max-width: calc(100vw - 28px);
640
- width: calc(100vw - 28px);
641
- }
642
-
643
- .hero-mark {
644
- height: 68px;
645
- width: 68px;
646
- }
647
-
648
- #objectverse-hero h1 {
649
- font-size: 32px;
650
- overflow-wrap: anywhere;
651
- }
652
-
653
- .hero-badges {
654
- justify-content: flex-start;
655
- }
656
-
657
- .hero-badges span {
658
- flex: 1 1 100%;
659
- text-align: center;
660
- }
661
-
662
- #archive-main-grid,
663
- #archive-bottom-grid,
664
- .gradio-container .gr-row {
665
- flex-direction: column !important;
666
- gap: 14px !important;
667
- }
668
-
669
- .archive-panel {
670
- padding: 14px;
671
- width: 100% !important;
672
- }
673
-
674
- #personality-mode label,
675
- .mode-switch label {
676
- flex-basis: 120px;
677
- }
678
-
679
- .example-section-title {
680
- align-items: flex-start;
681
- flex-direction: column;
682
- gap: 4px;
683
- }
684
-
685
- #diary-output {
686
- min-height: 240px;
687
- }
688
-
689
- .objectverse-card {
690
- max-width: 100%;
691
- }
692
- }
693
-
694
- @media (max-width: 430px) {
695
- .gradio-container {
696
- padding-left: 10px !important;
697
- padding-right: 10px !important;
698
- }
699
-
700
- #objectverse-hero,
701
- .archive-panel {
702
- border-radius: 7px;
703
- }
704
-
705
- .panel-header h2 {
706
- font-size: 17px;
707
- }
708
-
709
- .panel-header {
710
- gap: 9px;
711
- }
712
-
713
- #personality-mode label,
714
- .mode-switch label {
715
- flex-basis: 100%;
716
- }
717
-
718
- .object-file-card h3,
719
- .objectverse-card h2 {
720
- font-size: 25px;
721
- }
722
-
723
- .card-header {
724
- flex-direction: column;
725
- }
726
-
727
- .card-stamp {
728
- border-radius: 999px;
729
- height: auto;
730
- padding: 7px 10px;
731
- width: auto;
732
- }
733
- }
 
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
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_dataset_tooling.py CHANGED
@@ -10,6 +10,7 @@ from pathlib import Path
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 src.models.schema import TraceRecord
14
 
15
 
@@ -36,6 +37,29 @@ class DatasetToolingTest(unittest.TestCase):
36
  self.assertEqual(len(rows), 3)
37
  self.assertEqual(rows[0]["id"], "sft-preview-0001")
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def test_export_trace_jsonl(self) -> None:
40
  with tempfile.TemporaryDirectory() as tmp_dir:
41
  sample_dir = Path(tmp_dir) / "samples"
 
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
 
 
37
  self.assertEqual(len(rows), 3)
38
  self.assertEqual(rows[0]["id"], "sft-preview-0001")
39
 
40
+ def test_build_curated_records(self) -> None:
41
+ records = build_curated_records(10)
42
+ assistant_payload = json.loads(records[0]["messages"][2]["content"])
43
+
44
+ self.assertEqual(len(records), 10)
45
+ self.assertEqual(records[0]["split"], "train")
46
+ self.assertEqual(records[0]["source"], "objectverse-diary-synthetic-curated-v1")
47
+ self.assertIn("curation_notes", records[0])
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"
54
+ write_jsonl(build_curated_records(2), output_path)
55
+ rows = [
56
+ json.loads(line)
57
+ for line in output_path.read_text(encoding="utf-8").splitlines()
58
+ ]
59
+
60
+ self.assertEqual(len(rows), 2)
61
+ self.assertEqual(rows[0]["id"], "curated-synthetic-0001")
62
+
63
  def test_export_trace_jsonl(self) -> None:
64
  with tempfile.TemporaryDirectory() as tmp_dir:
65
  sample_dir = Path(tmp_dir) / "samples"
tests/test_finetune_lora_tooling.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Modal LoRA fine-tuning scaffolding 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 import finetune_lora
11
+
12
+
13
+ def _valid_record() -> dict[str, object]:
14
+ return {
15
+ "id": "sft-preview-0001",
16
+ "messages": [
17
+ {"role": "system", "content": "You are Objectverse Diary."},
18
+ {"role": "user", "content": "Create a persona."},
19
+ {"role": "assistant", "content": "{\"persona\": {}, \"diary\": {}}"},
20
+ ],
21
+ }
22
+
23
+
24
+ class FinetuneLoraToolingTest(unittest.TestCase):
25
+ def test_load_sft_records_rejects_missing_messages(self) -> None:
26
+ with tempfile.TemporaryDirectory() as tmp_dir:
27
+ path = Path(tmp_dir) / "bad.jsonl"
28
+ path.write_text(json.dumps({"id": "bad"}) + "\n", encoding="utf-8")
29
+
30
+ with self.assertRaises(ValueError):
31
+ finetune_lora.load_sft_records(path)
32
+
33
+ def test_load_sft_records_rejects_malformed_messages(self) -> None:
34
+ bad_record = {"id": "bad", "messages": [{"role": "user"}]}
35
+ with tempfile.TemporaryDirectory() as tmp_dir:
36
+ path = Path(tmp_dir) / "bad.jsonl"
37
+ path.write_text(json.dumps(bad_record) + "\n", encoding="utf-8")
38
+
39
+ with self.assertRaises(ValueError):
40
+ finetune_lora.load_sft_records(path)
41
+
42
+ def test_record_to_training_text_is_non_empty(self) -> None:
43
+ text = finetune_lora.record_to_training_text(_valid_record())
44
+
45
+ self.assertIn("system:", text)
46
+ self.assertIn("user:", text)
47
+ self.assertIn("assistant:", text)
48
+ self.assertIn("Objectverse Diary", text)
49
+
50
+ def test_default_training_config_uses_safe_qwen_lora_defaults(self) -> None:
51
+ config = finetune_lora.TrainingConfig()
52
+
53
+ self.assertEqual(config.base_model, "Qwen/Qwen2.5-1.5B-Instruct")
54
+ self.assertEqual(config.lora_r, 16)
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"
64
+ path.write_text(json.dumps(_valid_record()) + "\n", encoding="utf-8")
65
+
66
+ def fail_remote_call(
67
+ records: list[dict[str, object]],
68
+ config: finetune_lora.TrainingConfig,
69
+ ) -> dict[str, object]:
70
+ raise AssertionError("dry-run should not call remote training")
71
+
72
+ summary = finetune_lora.run_training_entrypoint(
73
+ dataset=path,
74
+ config=finetune_lora.TrainingConfig(),
75
+ dry_run=True,
76
+ allow_remote=False,
77
+ remote_runner=fail_remote_call,
78
+ )
79
+
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__":
86
+ unittest.main()
tests/test_publish_hf_adapter.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Hugging Face adapter 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_adapter import upload_adapter, validate_adapter_dir
10
+
11
+
12
+ class PublishHfAdapterTest(unittest.TestCase):
13
+ def test_validate_adapter_dir_requires_config_and_weights(self) -> None:
14
+ with tempfile.TemporaryDirectory() as tmp_dir:
15
+ adapter_dir = Path(tmp_dir)
16
+
17
+ with self.assertRaises(ValueError):
18
+ validate_adapter_dir(adapter_dir)
19
+
20
+ def test_upload_adapter_dry_run_does_not_require_hub_client(self) -> None:
21
+ with tempfile.TemporaryDirectory() as tmp_dir:
22
+ adapter_dir = Path(tmp_dir)
23
+ (adapter_dir / "adapter_config.json").write_text("{}", encoding="utf-8")
24
+ (adapter_dir / "adapter_model.safetensors").write_text("fake", encoding="utf-8")
25
+
26
+ summary = upload_adapter(
27
+ adapter_dir=adapter_dir,
28
+ repo_id="qqyule/objectverse-diary-lora-test",
29
+ private=False,
30
+ commit_message="Dry run",
31
+ dry_run=True,
32
+ )
33
+
34
+ self.assertFalse(summary["uploaded"])
35
+ self.assertEqual(summary["repo_id"], "qqyule/objectverse-diary-lora-test")
36
+ self.assertIn("adapter_config.json", summary["files"])
37
+
38
+
39
+ if __name__ == "__main__":
40
+ unittest.main()