github-actions commited on
Commit
638084e
Β·
1 Parent(s): f070f64

Sync from GitHub @ fa87356

Browse files
app.py CHANGED
@@ -36,7 +36,11 @@ from pydantic import BaseModel, Field
36
  # Make the local `knowledge` package importable whether we're running on HF
37
  # Spaces (cwd = /app) or locally (cwd = repo root, script in space/).
38
  sys.path.insert(0, str(Path(__file__).resolve().parent))
39
- from knowledge import KnowledgePipeline # noqa: E402
 
 
 
 
40
 
41
 
42
  # ────────────────────────────────────────────────────────────────────────────
@@ -583,6 +587,127 @@ def handle_delete_all() -> tuple[str, list]:
583
  return f"πŸ—‘οΈ Cleared {n} document(s).", render_knowledge_table()
584
 
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  def refresh_all():
587
  return (
588
  render_active_genome(),
@@ -702,6 +827,22 @@ with gr.Blocks(title="EvoLLM", theme=gr.themes.Soft(), css=CSS) as demo:
702
  refresh_btn = gr.Button("πŸ”„ Refresh")
703
  evolution_result = gr.Markdown("")
704
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
  # ── Tab 3: Knowledge ─────────────────────────────────────────
706
  with gr.Tab("πŸ“š Knowledge"):
707
  gr.Markdown(
@@ -752,6 +893,39 @@ with gr.Blocks(title="EvoLLM", theme=gr.themes.Soft(), css=CSS) as demo:
752
  clear_knowledge_btn = gr.Button("πŸ—‘οΈ Clear all documents", variant="stop")
753
  knowledge_action_status = gr.Markdown("")
754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  # ── Tab 4: Evolution Log ─────────────────────────────────────
756
  with gr.Tab("πŸ“œ Evolution Log"):
757
  gr.Markdown("### Lineage of mutations, promotions, and feedback events")
@@ -896,6 +1070,28 @@ Built on EvoTransformer (Mohabeer, 2025).
896
  api_name=False,
897
  ).then(render_knowledge_stats, None, knowledge_stats, api_name=False)
898
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
 
900
  if __name__ == "__main__":
901
  demo.queue().launch(
 
36
  # Make the local `knowledge` package importable whether we're running on HF
37
  # Spaces (cwd = /app) or locally (cwd = repo root, script in space/).
38
  sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ from knowledge import ( # noqa: E402
40
+ KnowledgePipeline,
41
+ generate_training_notebook,
42
+ import_adapter as import_adapter_files,
43
+ )
44
 
45
 
46
  # ────────────────────────────────────────────────────────────────────────────
 
587
  return f"πŸ—‘οΈ Cleared {n} document(s).", render_knowledge_table()
588
 
589
 
590
+ # ────────────────────────────────────────────────────────────────────────────
591
+ # LoRA-on-upload (Phase 4b)
592
+ # ────────────────────────────────────────────────────────────────────────────
593
+
594
+ NOTEBOOK_DIR = Path("data/notebooks")
595
+ NOTEBOOK_DIR.mkdir(parents=True, exist_ok=True)
596
+
597
+
598
+ def handle_generate_notebook(
599
+ selected_doc_names: list[str],
600
+ adapter_name: str,
601
+ lora_rank: int,
602
+ num_epochs: int,
603
+ ):
604
+ """Build a Colab notebook from the chunks of the selected documents."""
605
+ if not selected_doc_names:
606
+ return "_Select at least one indexed document first._", None
607
+ adapter_name = (adapter_name or "").strip() or "user_adapter"
608
+
609
+ # Gather chunks for the selected docs from the store.
610
+ with KNOWLEDGE.store._conn() as c: # noqa: SLF001 β€” internal access OK for now
611
+ rows = c.execute(
612
+ "SELECT documents.name as name, chunks.text as text "
613
+ "FROM chunks JOIN documents ON chunks.document_id = documents.id "
614
+ "WHERE documents.name IN (" + ",".join(["?"] * len(selected_doc_names)) + ") "
615
+ "ORDER BY chunks.document_id, chunks.chunk_index",
616
+ selected_doc_names,
617
+ ).fetchall()
618
+ chunks = [r["text"] for r in rows]
619
+ if not chunks:
620
+ return "_No chunks found for those documents β€” re-index them?_", None
621
+
622
+ safe_name = "".join(ch if ch.isalnum() else "_" for ch in adapter_name)[:40] or "user_adapter"
623
+ out_path = NOTEBOOK_DIR / f"evollm_train_{safe_name}.ipynb"
624
+
625
+ generate_training_notebook(
626
+ adapter_name=adapter_name,
627
+ chunks=chunks,
628
+ source_doc_names=selected_doc_names,
629
+ lora_rank=int(lora_rank),
630
+ num_epochs=int(num_epochs),
631
+ output_path=out_path,
632
+ description=f"Trained from {len(selected_doc_names)} document(s) via EvoLLM",
633
+ )
634
+
635
+ log_evolution(
636
+ "knowledge",
637
+ f"πŸ“ Generated training notebook for '{adapter_name}' "
638
+ f"({len(chunks)} chunks, {len(selected_doc_names)} doc(s))",
639
+ )
640
+
641
+ msg = (
642
+ f"βœ… **Notebook ready**: `{out_path.name}` ({len(chunks)} training examples).\n\n"
643
+ f"1. Download the file below\n"
644
+ f"2. Open it in [Google Colab](https://colab.research.google.com/)\n"
645
+ f"3. **Runtime β†’ Change runtime type β†’ T4 GPU**, then **Runtime β†’ Run all**\n"
646
+ f"4. After training, download the two output files (`*.gguf` and `*.json`)\n"
647
+ f"5. Come back here, go to **🧬 Adapter Pool** tab β†’ **πŸ“₯ Import trained adapter**"
648
+ )
649
+ return msg, str(out_path)
650
+
651
+
652
+ def handle_import_adapter(gguf_file, manifest_file):
653
+ """Receive a trained LoRA + manifest, register a new adapter in the pool."""
654
+ if not gguf_file or not manifest_file:
655
+ return "_Drop both the .gguf and the .json files._", render_pool_table()
656
+ try:
657
+ gguf_path = gguf_file if isinstance(gguf_file, str) else gguf_file.name
658
+ manifest_path = manifest_file if isinstance(manifest_file, str) else manifest_file.name
659
+ info = import_adapter_files(gguf_path, manifest_path)
660
+ except Exception as e:
661
+ return f"❌ Import failed: {e}", render_pool_table()
662
+
663
+ manifest = info["manifest"]
664
+ adapter_id = info["adapter_id"]
665
+
666
+ # Build a genome reflecting the trained-from-knowledge adapter.
667
+ sys_prompt = (
668
+ f"You are EvoLLM, fine-tuned on user-provided documents "
669
+ f"({', '.join(manifest.get('source_documents', []))[:160] or 'unknown sources'}). "
670
+ f"Draw on what you learned from those sources when relevant."
671
+ )
672
+ genome = Genome(
673
+ genome_id=adapter_id,
674
+ parent_id="evo_default",
675
+ generation=1,
676
+ name=info["name"],
677
+ base_model=manifest.get("base_model", "SmolLM2-1.7B-Instruct"),
678
+ lora_rank=int(manifest.get("lora_rank", 16)),
679
+ lora_alpha=int(manifest.get("lora_alpha", 32)),
680
+ lora_target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
681
+ system_prompt=sys_prompt,
682
+ eval_bank_score=None, # unmeasured until the eval bank runs against it
683
+ )
684
+ new_adapter = Adapter(
685
+ adapter_id=adapter_id,
686
+ name=info["name"],
687
+ description=info["description"] or "User-trained from documents",
688
+ genome=genome,
689
+ promoted=True,
690
+ )
691
+ POOL.append(new_adapter)
692
+ POOL_BY_ID[adapter_id] = new_adapter
693
+ BANDIT.register(adapter_id, prior_fitness=0.55) # neutral-mid prior
694
+
695
+ log_evolution(
696
+ "promotion",
697
+ f"πŸ“₯ IMPORTED user-trained adapter '{info['name']}' "
698
+ f"({manifest.get('training_examples', '?')} examples, rank {genome.lora_rank}) β€” joined the pool.",
699
+ {"adapter_id": adapter_id, "source_documents": manifest.get("source_documents", [])},
700
+ )
701
+
702
+ note = (
703
+ f"βœ… **{info['name']}** imported and added to the adapter pool.\n\n"
704
+ f"_GGUF saved to `{info['gguf_path']}`. The Bandit will start sampling it on the next "
705
+ f"chat. Real LoRA weight-loading is active in the local desktop app; on this Space the "
706
+ f"adapter uses its trained-from-data genome (system prompt + sampling config)._"
707
+ )
708
+ return note, render_pool_table()
709
+
710
+
711
  def refresh_all():
712
  return (
713
  render_active_genome(),
 
827
  refresh_btn = gr.Button("πŸ”„ Refresh")
828
  evolution_result = gr.Markdown("")
829
 
830
+ gr.Markdown("---")
831
+ gr.Markdown(
832
+ "### πŸ“₯ Import a trained adapter\n"
833
+ "Drop the two files produced by the Colab training notebook "
834
+ "(`*.gguf` and `*.json`) to add a user-trained adapter to the pool."
835
+ )
836
+ with gr.Row():
837
+ import_gguf = gr.File(
838
+ label="LoRA adapter (.gguf)", file_types=[".gguf"], type="filepath",
839
+ )
840
+ import_manifest = gr.File(
841
+ label="Manifest (.json)", file_types=[".json"], type="filepath",
842
+ )
843
+ import_btn = gr.Button("πŸ“₯ Import adapter", variant="primary")
844
+ import_status = gr.Markdown("")
845
+
846
  # ── Tab 3: Knowledge ─────────────────────────────────────────
847
  with gr.Tab("πŸ“š Knowledge"):
848
  gr.Markdown(
 
893
  clear_knowledge_btn = gr.Button("πŸ—‘οΈ Clear all documents", variant="stop")
894
  knowledge_action_status = gr.Markdown("")
895
 
896
+ gr.Markdown("---")
897
+ gr.Markdown(
898
+ "### 🧬 Train an adapter from these documents\n"
899
+ "Bake the document content into a real LoRA adapter via QLoRA on Colab. "
900
+ "EvoLLM generates a configured notebook with your data inline; you run it "
901
+ "on a free T4 GPU; then import the resulting `.gguf` + manifest back here."
902
+ )
903
+ with gr.Row():
904
+ with gr.Column(scale=2):
905
+ train_doc_select = gr.CheckboxGroup(
906
+ choices=[d["name"] for d in KNOWLEDGE.documents()],
907
+ label="Documents to train on",
908
+ info="Select one or more indexed documents.",
909
+ )
910
+ with gr.Column(scale=1):
911
+ train_adapter_name = gr.Textbox(
912
+ label="Adapter name", placeholder="e.g. company_handbook",
913
+ )
914
+ train_lora_rank = gr.Slider(
915
+ minimum=4, maximum=64, value=16, step=4,
916
+ label="LoRA rank",
917
+ info="Higher = more capacity, slower training",
918
+ )
919
+ train_num_epochs = gr.Slider(
920
+ minimum=1, maximum=10, value=3, step=1,
921
+ label="Training epochs",
922
+ )
923
+ with gr.Row():
924
+ refresh_train_docs_btn = gr.Button("πŸ”„ Refresh doc list", size="sm")
925
+ generate_notebook_btn = gr.Button("🧬 Generate training notebook", variant="primary")
926
+ notebook_status = gr.Markdown("")
927
+ notebook_download = gr.File(label="πŸ“’ Download notebook", interactive=False)
928
+
929
  # ── Tab 4: Evolution Log ─────────────────────────────────────
930
  with gr.Tab("πŸ“œ Evolution Log"):
931
  gr.Markdown("### Lineage of mutations, promotions, and feedback events")
 
1070
  api_name=False,
1071
  ).then(render_knowledge_stats, None, knowledge_stats, api_name=False)
1072
 
1073
+ # Refresh the doc selector when documents change (keep choices in sync)
1074
+ def _refresh_doc_choices():
1075
+ return gr.update(choices=[d["name"] for d in KNOWLEDGE.documents()])
1076
+
1077
+ refresh_train_docs_btn.click(
1078
+ _refresh_doc_choices, None, train_doc_select, api_name=False,
1079
+ )
1080
+
1081
+ generate_notebook_btn.click(
1082
+ handle_generate_notebook,
1083
+ [train_doc_select, train_adapter_name, train_lora_rank, train_num_epochs],
1084
+ [notebook_status, notebook_download],
1085
+ api_name=False,
1086
+ )
1087
+
1088
+ import_btn.click(
1089
+ handle_import_adapter,
1090
+ [import_gguf, import_manifest],
1091
+ [import_status, pool_table],
1092
+ api_name=False,
1093
+ ).then(refresh_all, None, refresh_outputs, api_name=False)
1094
+
1095
 
1096
  if __name__ == "__main__":
1097
  demo.queue().launch(
knowledge/__init__.py CHANGED
@@ -1,18 +1,31 @@
1
- """EvoLLM knowledge layer β€” RAG pipeline with multilingual embeddings.
2
 
3
  Components:
4
- - parser : PDF / TXT / MD / DOCX β†’ clean text
5
- - chunker : long text β†’ overlapping ~400-token chunks
6
- - embedder : multilingual MiniLM via fastembed (no torch dependency)
7
- - store : SQLite + numpy vector store
8
- - pipeline : high-level ingest / query API
 
 
 
9
 
10
- Local persistence at data/knowledge.sqlite. On HF Spaces the path is the
11
- container's ephemeral disk, so uploads vanish on rebuild β€” there's a visible
12
- notice in the UI explaining that.
13
  """
14
 
 
 
 
15
  from .pipeline import KnowledgePipeline
16
  from .store import KnowledgeStore
17
 
18
- __all__ = ["KnowledgePipeline", "KnowledgeStore"]
 
 
 
 
 
 
 
 
1
+ """EvoLLM knowledge layer β€” RAG + LoRA-on-upload.
2
 
3
  Components:
4
+ - parser : PDF / TXT / MD / DOCX β†’ clean text
5
+ - chunker : long text β†’ overlapping ~400-token chunks
6
+ - embedder : fastembed wrapper (no torch dependency)
7
+ - store : SQLite + numpy vector store
8
+ - pipeline : high-level ingest / query API
9
+ - dataset_builder : doc chunks β†’ training JSONL
10
+ - notebook_generator : produce a Colab notebook with the dataset baked in
11
+ - adapter_importer : accept uploaded GGUF + manifest, register an adapter
12
 
13
+ Local persistence at data/knowledge.sqlite + data/adapters/. On HF Spaces
14
+ the path is the container's ephemeral disk, so uploads vanish on rebuild
15
+ β€” there's a visible notice in the UI explaining that.
16
  """
17
 
18
+ from .adapter_importer import import_adapter
19
+ from .dataset_builder import build_dataset, write_jsonl
20
+ from .notebook_generator import generate_training_notebook
21
  from .pipeline import KnowledgePipeline
22
  from .store import KnowledgeStore
23
 
24
+ __all__ = [
25
+ "KnowledgePipeline",
26
+ "KnowledgeStore",
27
+ "build_dataset",
28
+ "write_jsonl",
29
+ "generate_training_notebook",
30
+ "import_adapter",
31
+ ]
knowledge/adapter_importer.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Import a trained LoRA adapter (GGUF + manifest) back into EvoLLM.
2
+
3
+ The Colab notebook produced by notebook_generator.py emits two files
4
+ at the end of a training run. The user re-uploads them via the
5
+ 'Import trained adapter' UI; we validate them, stash the GGUF, parse
6
+ the manifest, and surface an AdapterRecord-shaped dict so the caller
7
+ can register it in the live POOL + BANDIT.
8
+
9
+ We don't actually load the LoRA into llama.cpp here β€” that happens in
10
+ the local desktop app where reloading the Llama instance with
11
+ lora_path=... is acceptable. On the HF Space the imported adapter
12
+ joins the pool with its genome (system prompt + sampling), and the
13
+ bandit can pick it like any other variant; the GGUF and manifest are
14
+ stored for the local app's use.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import shutil
21
+ from pathlib import Path
22
+
23
+ DEFAULT_ADAPTER_DIR = Path("data/adapters")
24
+
25
+
26
+ def import_adapter(
27
+ gguf_path: str | Path,
28
+ manifest_path: str | Path,
29
+ dest_dir: str | Path = DEFAULT_ADAPTER_DIR,
30
+ ) -> dict:
31
+ """Validate + copy + return adapter info."""
32
+ gguf_path = Path(gguf_path)
33
+ manifest_path = Path(manifest_path)
34
+ dest_dir = Path(dest_dir)
35
+ dest_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ if not gguf_path.exists():
38
+ raise FileNotFoundError(f"Missing GGUF: {gguf_path}")
39
+ if not manifest_path.exists():
40
+ raise FileNotFoundError(f"Missing manifest: {manifest_path}")
41
+
42
+ try:
43
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
44
+ except json.JSONDecodeError as e:
45
+ raise ValueError(f"Manifest is not valid JSON: {e}") from e
46
+
47
+ for required in ("adapter_id", "name", "base_model"):
48
+ if required not in manifest:
49
+ raise ValueError(f"Manifest missing required field: {required}")
50
+
51
+ adapter_id = manifest["adapter_id"]
52
+ dest_gguf = dest_dir / f"{adapter_id}.gguf"
53
+ dest_manifest = dest_dir / f"{adapter_id}.json"
54
+
55
+ shutil.copy(gguf_path, dest_gguf)
56
+ shutil.copy(manifest_path, dest_manifest)
57
+
58
+ return {
59
+ "adapter_id": adapter_id,
60
+ "name": manifest["name"],
61
+ "description": manifest.get("description", ""),
62
+ "base_model": manifest["base_model"],
63
+ "gguf_path": str(dest_gguf),
64
+ "manifest_path": str(dest_manifest),
65
+ "manifest": manifest,
66
+ }
knowledge/dataset_builder.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert indexed document chunks into a training dataset.
2
+
3
+ Two output formats:
4
+ β€’ text mode : plain-LM continued pretraining ({"text": "..."} per row).
5
+ Best for vocabulary/language acquisition (e.g. Creole).
6
+ No teacher model required.
7
+ β€’ instruction : chat-style (system/user/assistant) where the user asks
8
+ for content and the assistant emits the chunk text.
9
+ Better for domain Q&A; still no teacher needed β€”
10
+ the chunk content itself is the target.
11
+
12
+ The pipeline writes JSONL because that's what trl.SFTTrainer ingests
13
+ directly.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Iterable, Literal
21
+
22
+ DatasetMode = Literal["text", "instruction"]
23
+
24
+
25
+ def build_dataset(
26
+ chunks: Iterable[str],
27
+ mode: DatasetMode = "text",
28
+ system_prompt: str = "You are EvoLLM, fine-tuned on user-provided documents.",
29
+ ) -> list[dict]:
30
+ chunks = [c.strip() for c in chunks if c and c.strip()]
31
+ if mode == "text":
32
+ return [{"text": c} for c in chunks]
33
+ if mode == "instruction":
34
+ return [
35
+ {
36
+ "messages": [
37
+ {"role": "system", "content": system_prompt},
38
+ {"role": "user", "content": "Continue the passage in the style of the source material."},
39
+ {"role": "assistant", "content": c},
40
+ ]
41
+ }
42
+ for c in chunks
43
+ ]
44
+ raise ValueError(f"Unknown dataset mode: {mode}")
45
+
46
+
47
+ def write_jsonl(rows: list[dict], path: str | Path) -> Path:
48
+ path = Path(path)
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ with path.open("w", encoding="utf-8") as f:
51
+ for row in rows:
52
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
53
+ return path
knowledge/notebook_generator.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a self-contained Colab notebook that QLoRA-trains an adapter
2
+ on the user's selected document chunks.
3
+
4
+ The user downloads the .ipynb from the EvoLLM UI, opens it in Colab
5
+ (free T4 is sufficient for small corpora), clicks 'Run all', and
6
+ downloads two files at the end: the LoRA adapter as GGUF and a
7
+ manifest.json. They re-upload both into EvoLLM via the 'Import trained
8
+ adapter' button, and the new adapter joins the pool.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import uuid
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+
19
+ def _cell(cell_type: str, source: str) -> dict:
20
+ return {
21
+ "cell_type": cell_type,
22
+ "metadata": {},
23
+ "source": source.splitlines(keepends=True),
24
+ **({"execution_count": None, "outputs": []} if cell_type == "code" else {}),
25
+ }
26
+
27
+
28
+ def generate_training_notebook(
29
+ adapter_name: str,
30
+ chunks: list[str],
31
+ source_doc_names: list[str],
32
+ base_model: str = "HuggingFaceTB/SmolLM2-1.7B-Instruct",
33
+ lora_rank: int = 16,
34
+ lora_alpha: int | None = None,
35
+ learning_rate: float = 2e-4,
36
+ num_epochs: int = 3,
37
+ batch_size: int = 2,
38
+ grad_accum: int = 4,
39
+ output_path: str | Path = "evollm_training_notebook.ipynb",
40
+ description: str = "",
41
+ ) -> Path:
42
+ """Produce a configured .ipynb the user can run on Colab."""
43
+ if lora_alpha is None:
44
+ lora_alpha = lora_rank * 2
45
+
46
+ adapter_id = f"user_{uuid.uuid4().hex[:8]}"
47
+ safe_adapter_name = adapter_name.strip() or adapter_id
48
+ created_at = datetime.utcnow().isoformat()
49
+
50
+ dataset_rows = [{"text": c} for c in chunks if c and c.strip()]
51
+
52
+ manifest = {
53
+ "adapter_id": adapter_id,
54
+ "name": safe_adapter_name,
55
+ "description": description or f"User-trained adapter on {len(source_doc_names)} document(s)",
56
+ "base_model": base_model,
57
+ "source_documents": source_doc_names,
58
+ "lora_rank": lora_rank,
59
+ "lora_alpha": lora_alpha,
60
+ "learning_rate": learning_rate,
61
+ "num_epochs": num_epochs,
62
+ "training_examples": len(dataset_rows),
63
+ "trained_at": created_at,
64
+ "trained_from_knowledge": True,
65
+ }
66
+
67
+ intro_md = f"""# EvoLLM β€” Train your own adapter
68
+
69
+ This notebook produces a **{safe_adapter_name}** LoRA adapter from your
70
+ selected documents.
71
+
72
+ **Source documents**: {", ".join(source_doc_names) or "(none)"}
73
+ **Base model**: `{base_model}`
74
+ **LoRA rank**: {lora_rank} (alpha = {lora_alpha})
75
+ **Epochs**: {num_epochs} Β· **LR**: {learning_rate} Β· **Examples**: {len(dataset_rows)}
76
+
77
+ ## How to run
78
+
79
+ 1. **Runtime β†’ Change runtime type β†’ T4 GPU** (or A100 if you have Colab Pro).
80
+ 2. Click **Runtime β†’ Run all**.
81
+ 3. When training finishes, you'll get two download links:
82
+ - `{adapter_id}.gguf` β€” the LoRA adapter in llama.cpp format
83
+ - `{adapter_id}.json` β€” the manifest
84
+ 4. Back in EvoLLM, go to the **🧬 Adapter Pool** tab β†’ **πŸ“₯ Import trained adapter** β†’ drop both files.
85
+
86
+ Approximate runtime on free T4: ~20–60 minutes for {len(dataset_rows)} examples.
87
+ """
88
+
89
+ setup_code = """!nvidia-smi
90
+ !pip install -q -U \\
91
+ "transformers>=4.46" "peft>=0.13" "trl>=0.12" \\
92
+ "datasets>=3.1" "accelerate>=1.1" "bitsandbytes>=0.44" "sentencepiece"
93
+ """
94
+
95
+ config_code = f"""import json, gc, torch
96
+ from pathlib import Path
97
+
98
+ ADAPTER_ID = "{adapter_id}"
99
+ ADAPTER_NAME = {json.dumps(safe_adapter_name)}
100
+ BASE_MODEL = {json.dumps(base_model)}
101
+ LORA_RANK = {lora_rank}
102
+ LORA_ALPHA = {lora_alpha}
103
+ LEARNING_RATE = {learning_rate}
104
+ NUM_EPOCHS = {num_epochs}
105
+ BATCH_SIZE = {batch_size}
106
+ GRAD_ACCUM = {grad_accum}
107
+
108
+ OUT_DIR = Path(f"/content/{{ADAPTER_ID}}")
109
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
110
+ """
111
+
112
+ # Inline the dataset as a JSON list. For small corpora this is fine;
113
+ # very large corpora should switch to a side file, but that's edge case.
114
+ dataset_code = "DATASET_ROWS = " + json.dumps(dataset_rows, ensure_ascii=False, indent=2)
115
+
116
+ manifest_code = (
117
+ "MANIFEST = " + json.dumps(manifest, ensure_ascii=False, indent=2)
118
+ )
119
+
120
+ train_code = """from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
121
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
122
+ from trl import SFTTrainer, SFTConfig
123
+ from datasets import Dataset
124
+
125
+ bnb = BitsAndBytesConfig(
126
+ load_in_4bit=True,
127
+ bnb_4bit_quant_type="nf4",
128
+ bnb_4bit_compute_dtype=torch.bfloat16,
129
+ bnb_4bit_use_double_quant=True,
130
+ )
131
+
132
+ print(f"Loading base: {BASE_MODEL}")
133
+ tok = AutoTokenizer.from_pretrained(BASE_MODEL)
134
+ if tok.pad_token is None:
135
+ tok.pad_token = tok.eos_token
136
+
137
+ model = AutoModelForCausalLM.from_pretrained(
138
+ BASE_MODEL, quantization_config=bnb, device_map="auto", torch_dtype=torch.bfloat16,
139
+ )
140
+ model = prepare_model_for_kbit_training(model)
141
+
142
+ peft_cfg = LoraConfig(
143
+ r=LORA_RANK, lora_alpha=LORA_ALPHA, lora_dropout=0.05,
144
+ bias="none", task_type="CAUSAL_LM",
145
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
146
+ )
147
+ model = get_peft_model(model, peft_cfg)
148
+ model.print_trainable_parameters()
149
+
150
+ ds = Dataset.from_list(DATASET_ROWS)
151
+
152
+ cfg = SFTConfig(
153
+ output_dir=str(OUT_DIR),
154
+ num_train_epochs=NUM_EPOCHS,
155
+ per_device_train_batch_size=BATCH_SIZE,
156
+ gradient_accumulation_steps=GRAD_ACCUM,
157
+ learning_rate=LEARNING_RATE,
158
+ bf16=True,
159
+ logging_steps=10,
160
+ save_strategy="epoch",
161
+ save_total_limit=1,
162
+ report_to="none",
163
+ max_seq_length=1024,
164
+ warmup_ratio=0.03,
165
+ dataset_text_field="text",
166
+ )
167
+
168
+ trainer = SFTTrainer(model=model, tokenizer=tok, train_dataset=ds, args=cfg)
169
+ trainer.train()
170
+ trainer.save_model(str(OUT_DIR))
171
+ print(f"\\nAdapter saved to {OUT_DIR}")
172
+
173
+ del model, trainer; gc.collect(); torch.cuda.empty_cache()
174
+ """
175
+
176
+ convert_code = """# Convert the LoRA adapter to GGUF for llama.cpp
177
+ !apt-get install -y -qq cmake build-essential
178
+ !git clone --depth 1 https://github.com/ggerganov/llama.cpp /content/llama.cpp 2>/dev/null || echo 'already cloned'
179
+ !pip install -q -r /content/llama.cpp/requirements/requirements-convert_lora_to_gguf.txt
180
+
181
+ GGUF_PATH = OUT_DIR / f"{ADAPTER_ID}.gguf"
182
+ !python /content/llama.cpp/convert_lora_to_gguf.py {OUT_DIR} --base {BASE_MODEL} --outfile {GGUF_PATH}
183
+
184
+ print(f"\\nGGUF adapter at: {GGUF_PATH}")
185
+ print(f"Size: {GGUF_PATH.stat().st_size / 1024 / 1024:.1f} MB")
186
+ """
187
+
188
+ package_code = """# Save manifest and prepare downloads
189
+ import shutil
190
+
191
+ manifest_path = OUT_DIR / f"{ADAPTER_ID}.json"
192
+ manifest_path.write_text(json.dumps(MANIFEST, ensure_ascii=False, indent=2))
193
+
194
+ # Stage the two files at /content for easy download
195
+ shutil.copy(GGUF_PATH, f"/content/{ADAPTER_ID}.gguf")
196
+ shutil.copy(manifest_path, f"/content/{ADAPTER_ID}.json")
197
+
198
+ print("\\n" + "=" * 60)
199
+ print("READY TO DOWNLOAD")
200
+ print("=" * 60)
201
+ print(f" /content/{ADAPTER_ID}.gguf")
202
+ print(f" /content/{ADAPTER_ID}.json")
203
+ print()
204
+ print("In the Colab Files panel (left side), right-click each file β†’ Download.")
205
+ print("Then in EvoLLM: 🧬 Adapter Pool tab β†’ πŸ“₯ Import trained adapter β†’ drop both files.")
206
+ """
207
+
208
+ notebook = {
209
+ "cells": [
210
+ _cell("markdown", intro_md),
211
+ _cell("markdown", "## 0. Setup"),
212
+ _cell("code", setup_code),
213
+ _cell("markdown", "## 1. Configuration & dataset"),
214
+ _cell("code", config_code),
215
+ _cell("code", dataset_code),
216
+ _cell("code", manifest_code),
217
+ _cell("markdown", "## 2. Train"),
218
+ _cell("code", train_code),
219
+ _cell("markdown", "## 3. Convert to GGUF"),
220
+ _cell("code", convert_code),
221
+ _cell("markdown", "## 4. Package for EvoLLM"),
222
+ _cell("code", package_code),
223
+ ],
224
+ "metadata": {
225
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
226
+ "language_info": {"name": "python", "version": "3.10"},
227
+ "accelerator": "GPU",
228
+ "colab": {"provenance": [], "gpuType": "T4"},
229
+ },
230
+ "nbformat": 4,
231
+ "nbformat_minor": 4,
232
+ }
233
+
234
+ output_path = Path(output_path)
235
+ output_path.parent.mkdir(parents=True, exist_ok=True)
236
+ output_path.write_text(json.dumps(notebook, ensure_ascii=False, indent=1))
237
+ return output_path