Spaces:
Running on Zero
Running on Zero
Update Objectverse Diary submission package
Browse files- README.md +9 -6
- data/train/objectverse_sft_curated.jsonl +0 -0
- docs/DATASET.md +57 -0
- docs/DEVELOPMENT_STATUS.md +6 -3
- docs/MODEL_CARD.md +29 -6
- docs/SUBMISSION_GUIDE.md +7 -5
- requirements-training.txt +2 -0
- scripts/README.md +57 -2
- scripts/finetune_lora.py +426 -0
- scripts/prepare_curated_dataset.py +275 -0
- scripts/publish_hf_adapter.py +104 -0
- src/ui/layout.py +206 -101
- src/ui/styles.css +618 -733
- tests/test_dataset_tooling.py +24 -0
- tests/test_finetune_lora_tooling.py +86 -0
- tests/test_publish_hf_adapter.py +40 -0
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,
|
| 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
|
| 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 |
-
- [
|
| 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
|
|
|
|
| 83 |
|
| 84 |
-
The stable public demo therefore stays within the 32B budget.
|
| 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
|
| 44 |
-
-
|
| 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.
|
| 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;
|
| 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 |
-
|
|
| 37 |
-
|
|
| 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 |
-
- [
|
| 10 |
-
- [
|
| 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,
|
| 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 |
-
- [
|
| 52 |
-
- [
|
| 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,
|
|
|
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
""",
|
| 89 |
-
padding=False,
|
| 90 |
-
)
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
)
|
| 120 |
-
generate_button = gr.Button(copy.GENERATE_LABEL, variant="primary", elem_id="wake-button")
|
| 121 |
|
|
|
|
|
|
|
| 122 |
gr.HTML(
|
| 123 |
-
"""
|
| 124 |
-
<
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
""",
|
| 129 |
padding=False,
|
| 130 |
)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
.
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
.
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
.
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
.
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
}
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
}
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
}
|
| 533 |
-
|
| 534 |
-
.
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
}
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
}
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 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()
|