Skip to content

Book graph

gutenberg_kg.bookgraph

bookgraph.py — reading one book's knowledge graph off disk.

Corpus discovery and per-book graph loading, with no rendering stack attached: plain sqlite3 and the layout node/edge records the geometry layer consumes. Kept apart from :mod:gutenberg_kg.scene so the whole POV-Ray pipeline — find a book, grow it, write the .pov — runs without an installed PyVista.

:mod:gutenberg_kg.scene re-exports every name here, so existing imports keep working.

Author: Eric G. Suchanek, PhD

BookMeta(title, genre, book_dir) dataclass

Lightweight metadata for one corpus book.

db_path property

Path to this book's KG SQLite graph file (DocKG or DiaryKG).

has_kg property

Whether any of :data:KG_DIRS holds a non-trivial graph.

kg_dir property

First of :data:KG_DIRS holding a non-trivial graph, else None.

slug property

URL-safe identifier, used as a namespace prefix.

load_book_graph(meta)

Load nodes and edges from a book's DocKG SQLite, prefixing all IDs with the book slug to prevent collisions when merging multiple books.

Parameters:

Name Type Description Default
meta BookMeta

Book metadata including slug and db_path.

required

Returns:

Type Description
tuple[list[LayoutNode], list[LayoutEdge]]

(nodes, edges) with namespaced IDs.

Source code in src/gutenberg_kg/bookgraph.py
 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
def load_book_graph(meta: BookMeta) -> tuple[list[LayoutNode], list[LayoutEdge]]:
    """
    Load nodes and edges from a book's DocKG SQLite, prefixing all IDs with
    the book slug to prevent collisions when merging multiple books.

    :param meta: Book metadata including slug and db_path.
    :return: ``(nodes, edges)`` with namespaced IDs.
    """
    prefix = f"{meta.slug}:"
    nodes: list[LayoutNode] = []
    edges: list[LayoutEdge] = []

    with sqlite3.connect(meta.db_path) as con:
        for row in con.execute("SELECT id, kind, name, title, file_path, text FROM nodes"):
            nid, kind, name, title, file_path, text = row
            display_name = title or name or nid
            docstring = text[:500] if text else None
            nodes.append(
                LayoutNode(
                    id=prefix + nid,
                    kind=kind,
                    name=display_name,
                    module_path=file_path,
                    docstring=docstring,
                )
            )
        for row in con.execute("SELECT src, rel, dst FROM edges"):
            src, rel, dst = row
            edges.append(LayoutEdge(src=prefix + src, rel=rel, dst=prefix + dst))

    return nodes, edges

load_entry_times(meta)

Earliest chunk timestamp per document, for books that carry dates.

DiaryKG stamps every chunk with the entry's date but leaves the document row's timestamp null, so the entry's date has to come up through its chunks. The result lets :class:ForestLayout grow a diary's limbs from its chronology — one limb per year — rather than from position in the file.

Parameters:

Name Type Description Default
meta BookMeta

Book metadata.

required

Returns:

Type Description
dict[str, str]

{namespaced document id: ISO timestamp}; empty for books whose graph carries no timestamps at all.

Source code in src/gutenberg_kg/bookgraph.py
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
def load_entry_times(meta: BookMeta) -> dict[str, str]:
    """
    Earliest chunk timestamp per document, for books that carry dates.

    DiaryKG stamps every chunk with the entry's date but leaves the document
    row's ``timestamp`` null, so the entry's date has to come up through its
    chunks.  The result lets :class:`ForestLayout` grow a diary's limbs from
    its chronology — one limb per year — rather than from position in the file.

    :param meta: Book metadata.
    :return: ``{namespaced document id: ISO timestamp}``; empty for books
        whose graph carries no timestamps at all.
    """
    prefix = f"{meta.slug}:"
    with sqlite3.connect(meta.db_path) as con:
        try:
            rows = con.execute(
                "SELECT e.src, MIN(n.timestamp) FROM edges e "
                "JOIN nodes n ON n.id = e.dst "
                "WHERE e.rel = 'CONTAINS' AND n.kind = 'chunk' AND n.timestamp IS NOT NULL "
                "GROUP BY e.src"
            ).fetchall()
        except sqlite3.OperationalError:
            # DocKG graphs predate the timestamp column; they simply have no dates.
            return {}
    return {prefix + src: ts for src, ts in rows if ts}

scan_corpus(corpus_root)

Walk corpus_root and return {genre: [BookMeta, ...]} for every book directory that carries a graph in one of :data:KG_DIRS.

Parameters:

Name Type Description Default
corpus_root Path

Root of the corpus directory tree.

required

Returns:

Type Description
dict[str, list[BookMeta]]

Dict mapping genre name to list of book metadata objects.

Source code in src/gutenberg_kg/bookgraph.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def scan_corpus(corpus_root: Path) -> dict[str, list[BookMeta]]:
    """
    Walk ``corpus_root`` and return ``{genre: [BookMeta, ...]}`` for every
    book directory that carries a graph in one of :data:`KG_DIRS`.

    :param corpus_root: Root of the corpus directory tree.
    :return: Dict mapping genre name to list of book metadata objects.
    """
    result: dict[str, list[BookMeta]] = defaultdict(list)
    for genre_dir in sorted(corpus_root.iterdir()):
        if not genre_dir.is_dir() or genre_dir.name.startswith("."):
            continue
        genre = genre_dir.name
        for book_dir in sorted(genre_dir.iterdir()):
            if not book_dir.is_dir() or book_dir.name.startswith("."):
                continue
            meta = BookMeta(
                title=book_dir.name,
                genre=genre,
                book_dir=book_dir,
            )
            if meta.has_kg:
                result[genre].append(meta)
    return dict(result)