From 5c2d6eba9e805a2717a7eebb20fd0a063061b9e7 Mon Sep 17 00:00:00 2001 From: mountain Date: Fri, 22 May 2026 22:09:37 +0800 Subject: [PATCH 01/25] feat(deck): scaffold openkb/deck/ package with path helpers --- openkb/deck/__init__.py | 33 +++++++++++++++++++++++++++++++++ tests/test_deck_package.py | 18 ++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 openkb/deck/__init__.py create mode 100644 tests/test_deck_package.py diff --git a/openkb/deck/__init__.py b/openkb/deck/__init__.py new file mode 100644 index 00000000..79fa62c1 --- /dev/null +++ b/openkb/deck/__init__.py @@ -0,0 +1,33 @@ +"""Deck target — HTML slide-deck generator. Second Generator target after skill. + +This package mirrors openkb/skill/ structurally. Today it owns: +* path construction — ``deck_dir`` / ``decks_root`` / ``deck_workspace_dir`` + +A deck is a single self-contained ``index.html`` file at +``/output/decks//index.html``. Workspace iteration history lives +at ``/output/decks/-workspace/iteration-N/``. +""" +from __future__ import annotations + +from pathlib import Path + +__all__ = [ + "decks_root", + "deck_dir", + "deck_workspace_dir", +] + + +def decks_root(kb_dir: Path) -> Path: + """``/output/decks`` — the directory holding every compiled deck.""" + return kb_dir / "output" / "decks" + + +def deck_dir(kb_dir: Path, deck_name: str) -> Path: + """``/output/decks/`` — one compiled deck's home.""" + return decks_root(kb_dir) / deck_name + + +def deck_workspace_dir(kb_dir: Path, deck_name: str) -> Path: + """``/output/decks/-workspace`` — iteration history for a deck.""" + return decks_root(kb_dir) / f"{deck_name}-workspace" diff --git a/tests/test_deck_package.py b/tests/test_deck_package.py new file mode 100644 index 00000000..cde0aace --- /dev/null +++ b/tests/test_deck_package.py @@ -0,0 +1,18 @@ +"""Sanity test for deck package path helpers — mirrors test_skill_factory expectations.""" +from __future__ import annotations + +from pathlib import Path + +from openkb.deck import deck_dir, deck_workspace_dir, decks_root + + +def test_decks_root(tmp_path: Path): + assert decks_root(tmp_path) == tmp_path / "output" / "decks" + + +def test_deck_dir(tmp_path: Path): + assert deck_dir(tmp_path, "transformers") == tmp_path / "output" / "decks" / "transformers" + + +def test_deck_workspace_dir(tmp_path: Path): + assert deck_workspace_dir(tmp_path, "transformers") == tmp_path / "output" / "decks" / "transformers-workspace" From 57684eae70ffc5534f30fc4c5ac3849875536a68 Mon Sep 17 00:00:00 2001 From: mountain Date: Fri, 22 May 2026 22:13:39 +0800 Subject: [PATCH 02/25] feat(deck): add write_deck_file + read_deck_file tools --- openkb/deck/tools.py | 59 ++++++++++++++++++++++++++++++++++++++++ tests/test_deck_tools.py | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 openkb/deck/tools.py create mode 100644 tests/test_deck_tools.py diff --git a/openkb/deck/tools.py b/openkb/deck/tools.py new file mode 100644 index 00000000..bf9f145e --- /dev/null +++ b/openkb/deck/tools.py @@ -0,0 +1,59 @@ +"""Path-scoped IO tools for the deck-create and deck-critic agents. + +* WRITE under deck root — ``write_deck_file`` +* READ from deck root — ``read_deck_file`` + +Wiki read tools (``list_wiki_dir``, ``read_wiki_file_for_skill``, +``get_skill_page_content``, ``read_skill_image``) are reused verbatim from +``openkb.skill.tools`` — no duplication. Importers should pull those +directly from there. + +Write boundary is enforced at the Python level: every path resolves and +must stay inside the deck root. Absolute paths and ``..`` traversal are +rejected outright. Mirror of ``openkb/skill/tools.py::write_skill_file``. +""" +from __future__ import annotations + +from pathlib import Path + + +def write_deck_file(path: str, content: str, deck_root: str) -> str: + """Write a file under the deck directory. + + Args: + path: Path relative to *deck_root* (e.g. ``"index.html"``). + Absolute paths and ``..`` traversal are rejected. + content: File contents. + deck_root: Absolute path to ``/output/decks/``. + """ + if path.startswith("/") or ".." in Path(path).parts: + return "Access denied: only relative paths within the deck directory are allowed." + root = Path(deck_root).resolve() + full = (root / path).resolve() + if not full.is_relative_to(root): + return "Access denied: path escapes deck root." + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + return f"Written: {path}" + + +def read_deck_file(path: str, deck_root: str) -> str: + """Read a file from the deck directory. + + Used by the critic agent to re-read the draft ``index.html`` for + revision — main's ``write_deck_file`` result string is "Written: X", + not the HTML body, so critic needs an explicit re-read tool. + + Args: + path: Path relative to *deck_root* (e.g. ``"index.html"``). + deck_root: Absolute path to ``/output/decks/``. + """ + if path.startswith("/") or ".." in Path(path).parts: + return "Access denied: only relative paths within the deck directory are allowed." + root = Path(deck_root).resolve() + full = (root / path).resolve() + if not full.is_relative_to(root): + return "Access denied: path escapes deck root." + if not full.exists(): + return f"File not found: {path}" + return full.read_text(encoding="utf-8") diff --git a/tests/test_deck_tools.py b/tests/test_deck_tools.py new file mode 100644 index 00000000..c80a1ea1 --- /dev/null +++ b/tests/test_deck_tools.py @@ -0,0 +1,47 @@ +"""Tests for deck-scoped IO tools. Mirrors tests/test_skill_tools.py for write_skill_file.""" +from __future__ import annotations + +from pathlib import Path + +from openkb.deck.tools import read_deck_file, write_deck_file + + +def test_write_then_read_roundtrip(tmp_path: Path): + deck_root = tmp_path / "deck" + msg = write_deck_file("index.html", "hi", str(deck_root)) + assert msg.startswith("Written:") + content = read_deck_file("index.html", str(deck_root)) + assert "hi" in content + + +def test_write_creates_parent_dirs(tmp_path: Path): + deck_root = tmp_path / "deck" + write_deck_file("nested/sub/file.txt", "ok", str(deck_root)) + assert (deck_root / "nested" / "sub" / "file.txt").read_text() == "ok" + + +def test_write_rejects_absolute_path(tmp_path: Path): + msg = write_deck_file("/etc/passwd", "pwn", str(tmp_path / "deck")) + assert "Access denied" in msg + assert not Path("/etc/passwd_test_marker").exists() # sanity + + +def test_write_rejects_parent_traversal(tmp_path: Path): + deck_root = tmp_path / "deck" + msg = write_deck_file("../escape.txt", "pwn", str(deck_root)) + assert "Access denied" in msg + assert not (tmp_path / "escape.txt").exists() + + +def test_read_rejects_escape(tmp_path: Path): + deck_root = tmp_path / "deck" + deck_root.mkdir() + msg = read_deck_file("../../etc/passwd", str(deck_root)) + assert "Access denied" in msg + + +def test_read_missing_file(tmp_path: Path): + deck_root = tmp_path / "deck" + deck_root.mkdir() + msg = read_deck_file("index.html", str(deck_root)) + assert "not found" in msg.lower() From 31ee46fe277b3f601f9e114cdebf91bae15379db Mon Sep 17 00:00:00 2001 From: mountain Date: Fri, 22 May 2026 22:20:08 +0800 Subject: [PATCH 03/25] feat(deck): structural validator with error/warning ValidationResult --- openkb/deck/validator.py | 145 +++++++++++++++++++++++++++++++ tests/test_deck_validator.py | 164 +++++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 openkb/deck/validator.py create mode 100644 tests/test_deck_validator.py diff --git a/openkb/deck/validator.py b/openkb/deck/validator.py new file mode 100644 index 00000000..aea8b555 --- /dev/null +++ b/openkb/deck/validator.py @@ -0,0 +1,145 @@ +"""Structural validation for a generated deck. + +Mirrors ``openkb/skill/validator.py``'s ``ValidationResult`` shape so +callers (``Generator.run``, the CLI's error reporter, the chat slash +command) can format results identically regardless of artifact type. + +Errors block declare-success; warnings print but allow the deck to ship. +The file is preserved even on error so the user can inspect the failure. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from html.parser import HTMLParser +from pathlib import Path + +__all__ = ["ALLOWED_DATA_TYPES", "ValidationResult", "validate_deck"] + + +ALLOWED_DATA_TYPES: frozenset[str] = frozenset( + {"cover", "chapter", "thesis", "quote", "compare", "data", "closing"} +) + +MAX_FILE_BYTES = 2 * 1024 * 1024 # 2 MB +MIN_SLIDES_HARD = 5 # error threshold +MIN_SLIDES_SOFT = 8 # warning threshold (count outside [8,15]) +MAX_SLIDES_SOFT = 15 +MIN_DISTINCT_TYPES = 4 +MAX_CONSECUTIVE_SAME_TYPE = 2 # warning if run-length >= 3 + + +@dataclass +class ValidationResult: + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +class _DeckParser(HTMLParser): + """Collects
blocks and external refs.""" + + def __init__(self) -> None: + super().__init__() + self.slide_types: list[str] = [] # data-type, in document order + self.external_links: list[str] = [] # offending href/src values + self._depth_in_slide = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + a = dict(attrs) + if tag == "section" and "slide" in (a.get("class") or "").split(): + self.slide_types.append((a.get("data-type") or "").strip()) + elif tag == "link": + href = (a.get("href") or "").strip() + if href.startswith(("http://", "https://", "//")): + self.external_links.append(f"") + elif tag == "script": + src = (a.get("src") or "").strip() + if src.startswith(("http://", "https://", "//")): + self.external_links.append(f"', + ) + result = validate_deck(_write(tmp_path, html)) + assert any("not self-contained" in e for e in result.errors) + + +def test_few_slides_warning(tmp_path: Path): + # 6 slides — passes hard floor (5), but warns (< 8). + html = ( + "" + '
' + '
' + '
' + '
' + '
' + '
' + "" + ) + result = validate_deck(_write(tmp_path, html)) + assert result.errors == [] + assert any("slide count 6" in w for w in result.warnings) + + +def test_run_of_3_same_type_warning(tmp_path: Path): + html = ( + "" + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + "" + ) + result = validate_deck(_write(tmp_path, html)) + assert result.errors == [] + assert any("consecutive" in w for w in result.warnings) + + +def test_low_distinct_types_warning(tmp_path: Path): + # 8 slides but only 3 distinct types (cover, thesis, closing). + html = ( + "" + '
' + + '
' * 6 + + '
' + "" + ) + result = validate_deck(_write(tmp_path, html)) + # Errors fine; this run-length and distinct-count will both warn. + assert any("distinct data-type" in w for w in result.warnings) + + +def test_oversize_file_warning(tmp_path: Path, monkeypatch): + # Inject a fake stat() so we don't actually allocate 2MB. + import openkb.deck.validator as v + + monkeypatch.setattr(v, "MAX_FILE_BYTES", 100) # threshold 100 bytes for the test + result = validate_deck(_write(tmp_path, GOOD_DECK)) + assert any("MB" in w for w in result.warnings) From 128706c23956c48e04f7da31cfc9e3c798dd7b02 Mon Sep 17 00:00:00 2001 From: mountain Date: Fri, 22 May 2026 22:26:33 +0800 Subject: [PATCH 04/25] fix(deck): flag external , suppress distinct-type warning on empty deck --- openkb/deck/validator.py | 6 +++++- tests/test_deck_validator.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/openkb/deck/validator.py b/openkb/deck/validator.py index aea8b555..6293d42d 100644 --- a/openkb/deck/validator.py +++ b/openkb/deck/validator.py @@ -59,6 +59,10 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None src = (a.get("src") or "").strip() if src.startswith(("http://", "https://", "//")): self.external_links.append(f"