langchain-okf 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ """LangChain integration for the Open Knowledge Format (OKF).
2
+
3
+ Provides document loaders, retrievers, graph utilities, and agent tools
4
+ for working with OKF knowledge bundles in LangChain pipelines.
5
+ """
6
+
7
+ from langchain_okf.document_loader import OKFBundleLoader
8
+ from langchain_okf.generator import OKFBundleGenerator
9
+ from langchain_okf.graph import OKFKnowledgeGraph
10
+ from langchain_okf.retriever import OKFRetriever
11
+ from langchain_okf.toolkit import (
12
+ OKFListConceptsTool,
13
+ OKFReadConceptTool,
14
+ OKFRelatedConceptsTool,
15
+ OKFSearchTool,
16
+ OKFToolkit,
17
+ OKFVerifyConceptTool,
18
+ OKFWriteConceptTool,
19
+ )
20
+ from langchain_okf.validate import validate_bundle
21
+
22
+ __all__ = [
23
+ "OKFBundleGenerator",
24
+ "OKFBundleLoader",
25
+ "OKFKnowledgeGraph",
26
+ "OKFRetriever",
27
+ "OKFListConceptsTool",
28
+ "OKFReadConceptTool",
29
+ "OKFRelatedConceptsTool",
30
+ "OKFSearchTool",
31
+ "OKFVerifyConceptTool",
32
+ "OKFWriteConceptTool",
33
+ "OKFToolkit",
34
+ "validate_bundle",
35
+ ]
@@ -0,0 +1,289 @@
1
+ """OKF concept document parser — OKF v0.2.
2
+
3
+ Aligns with OKF v0.2 specification:
4
+ https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
5
+
6
+ Key v0.2 additions over v0.1:
7
+ - ``generated`` / ``verified`` trust families (§5.2)
8
+ - ``sources`` provenance list (§5.1)
9
+ - ``status`` and ``stale_after`` lifecycle fields (§5.4, §5.5)
10
+ - Actor convention for ``generated.by`` / ``verified[].by`` (§7)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from datetime import date, datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ _FRONTMATTER_DELIM = "---"
24
+ _RESERVED_FILENAMES = {"index.md", "log.md"}
25
+ _CROSS_LINK_RE = re.compile(
26
+ r"\[([^\]]*)\]\((/[^)]+\.md|\.\./[^)]+\.md|(?!\w+://)[^)]+\.md)\)"
27
+ )
28
+
29
+
30
+ def _to_iso8601_str(val: Any) -> str:
31
+ """Normalize datetime/date or string values to ISO 8601 UTC string."""
32
+ if val is None or val == "":
33
+ return ""
34
+ if isinstance(val, (datetime, date)):
35
+ if isinstance(val, datetime) and val.tzinfo is not None:
36
+ return val.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
37
+ return val.strftime("%Y-%m-%dT%H:%M:%SZ")
38
+ return str(val)
39
+
40
+
41
+ class OKFParseError(ValueError):
42
+ """Raised when an OKF document cannot be parsed."""
43
+
44
+
45
+ @dataclass
46
+ class OKFConcept:
47
+ """A parsed OKF v0.2 concept document.
48
+
49
+ Attributes:
50
+ concept_id: Slash-separated path identity (e.g. ``tables/orders``).
51
+ frontmatter: Parsed YAML frontmatter dict.
52
+ body: Markdown body after frontmatter.
53
+ source_path: Absolute path to the source ``.md`` file.
54
+ """
55
+
56
+ concept_id: str
57
+ frontmatter: dict[str, Any] = field(default_factory=dict)
58
+ body: str = ""
59
+ source_path: str = ""
60
+ _cached_links: list[str] | None = field(
61
+ default=None, init=False, repr=False, compare=False
62
+ )
63
+
64
+ # ── Core fields (§4.1) ────────────────────────────────────────────────────
65
+
66
+ @property
67
+ def type(self) -> str:
68
+ return str(self.frontmatter.get("type", ""))
69
+
70
+ @property
71
+ def title(self) -> str:
72
+ return str(self.frontmatter.get("title", ""))
73
+
74
+ @property
75
+ def description(self) -> str:
76
+ return str(self.frontmatter.get("description", ""))
77
+
78
+ @property
79
+ def resource(self) -> str:
80
+ return str(self.frontmatter.get("resource", ""))
81
+
82
+ @property
83
+ def tags(self) -> list[str]:
84
+ t = self.frontmatter.get("tags", [])
85
+ return t if isinstance(t, list) else [t]
86
+
87
+ # ── v0.1 compat ──────────────────────────────────────────────────────────
88
+
89
+ @property
90
+ def timestamp(self) -> str:
91
+ """Legacy v0.1 field. Prefer ``generated_at``."""
92
+ return str(self.frontmatter.get("timestamp", ""))
93
+
94
+ # ── Trust: generated (§5.2) ───────────────────────────────────────────────
95
+
96
+ @property
97
+ def generated(self) -> dict[str, str]:
98
+ """``generated: { by, at }`` block, or empty dict."""
99
+ g = self.frontmatter.get("generated", {})
100
+ return g if isinstance(g, dict) else {}
101
+
102
+ @property
103
+ def generated_by(self) -> str:
104
+ return self.generated.get("by", "")
105
+
106
+ @property
107
+ def generated_at(self) -> str:
108
+ """ISO 8601 datetime string of last meaningful content change."""
109
+ return _to_iso8601_str(self.generated.get("at", self.timestamp))
110
+
111
+ # ── Trust: verified (§5.2) ────────────────────────────────────────────────
112
+
113
+ @property
114
+ def verified(self) -> list[dict[str, str]]:
115
+ """Verification events as a list of ``{ by, at }`` dicts.
116
+
117
+ Per spec §5.2, a bare mapping is treated as a one-element list.
118
+ """
119
+ v = self.frontmatter.get("verified")
120
+ if v is None:
121
+ return []
122
+ raw = [v] if isinstance(v, dict) else (v if isinstance(v, list) else [])
123
+ out = []
124
+ for e in raw:
125
+ if isinstance(e, dict):
126
+ item = dict(e)
127
+ if "at" in item:
128
+ item["at"] = _to_iso8601_str(item["at"])
129
+ out.append(item)
130
+ return out
131
+
132
+ @property
133
+ def trust_tier(self) -> str:
134
+ """Derived trust tier per §5.3.
135
+
136
+ Returns:
137
+ ``"human-reviewed"`` if any verifier uses ``human:`` prefix.
138
+ ``"machine-confirmed"`` if verified by non-human actors only.
139
+ ``"unverified"`` if ``verified`` is absent or empty.
140
+ """
141
+ events = self.verified
142
+ if not events:
143
+ return "unverified"
144
+ if any(e.get("by", "").startswith("human:") for e in events):
145
+ return "human-reviewed"
146
+ return "machine-confirmed"
147
+
148
+ # ── Lifecycle: status (§5.4) ──────────────────────────────────────────────
149
+
150
+ @property
151
+ def status(self) -> str:
152
+ """``draft`` | ``stable`` | ``deprecated``. Defaults to ``stable``."""
153
+ return str(self.frontmatter.get("status", "stable"))
154
+
155
+ # ── Lifecycle: stale_after (§5.5) ─────────────────────────────────────────
156
+
157
+ @property
158
+ def stale_after(self) -> str:
159
+ return _to_iso8601_str(self.frontmatter.get("stale_after", ""))
160
+
161
+ @property
162
+ def is_stale(self) -> bool:
163
+ """True when ``now >= stale_after`` (§5.5)."""
164
+ sa = self.stale_after
165
+ if not sa:
166
+ return False
167
+ try:
168
+ dt = datetime.fromisoformat(sa.replace("Z", "+00:00"))
169
+ return datetime.now(timezone.utc) >= dt
170
+ except (ValueError, TypeError):
171
+ return False
172
+
173
+ # ── Provenance: sources (§5.1) ────────────────────────────────────────────
174
+
175
+ @property
176
+ def sources(self) -> list[dict[str, Any]]:
177
+ """List of source provenance entries per §5.1."""
178
+ s = self.frontmatter.get("sources", [])
179
+ return s if isinstance(s, list) else []
180
+
181
+ # ── Attested Computation fields (§10) ─────────────────────────────────────
182
+
183
+ @property
184
+ def runtime(self) -> str:
185
+ """Computation runtime (e.g. ``bigquery``, ``python``). §10."""
186
+ return str(self.frontmatter.get("runtime", ""))
187
+
188
+ @property
189
+ def parameters(self) -> list[dict[str, Any]]:
190
+ """Declared parameters for an Attested Computation. §10."""
191
+ p = self.frontmatter.get("parameters", [])
192
+ return p if isinstance(p, list) else []
193
+
194
+ @property
195
+ def executor(self) -> dict[str, Any]:
196
+ """Executor block ``{ resource, receipt }``. §10."""
197
+ e = self.frontmatter.get("executor", {})
198
+ return e if isinstance(e, dict) else {}
199
+
200
+ @property
201
+ def attester(self) -> dict[str, Any]:
202
+ """Attester block ``{ resource }``. §10."""
203
+ a = self.frontmatter.get("attester", {})
204
+ return a if isinstance(a, dict) else {}
205
+
206
+ # ── Graph links ───────────────────────────────────────────────────────────
207
+
208
+ def cross_links(self) -> list[str]:
209
+ """Extract concept IDs referenced via markdown links in the body."""
210
+ if self._cached_links is not None:
211
+ return self._cached_links
212
+ links: list[str] = []
213
+ for _, href in _CROSS_LINK_RE.findall(self.body):
214
+ if href.endswith(".md"):
215
+ href = href[:-3]
216
+ href = href.lstrip("./")
217
+ if href.startswith("/"):
218
+ href = href[1:]
219
+ if href:
220
+ links.append(href)
221
+ self._cached_links = links
222
+ return links
223
+
224
+
225
+ # ── Parsing helpers ───────────────────────────────────────────────────────────
226
+
227
+
228
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
229
+ """Split an OKF markdown file into frontmatter dict and body string."""
230
+ lines = text.splitlines()
231
+ if not lines or lines[0].strip() != _FRONTMATTER_DELIM:
232
+ return {}, text
233
+
234
+ end_idx = None
235
+ for i in range(1, len(lines)):
236
+ if lines[i].strip() == _FRONTMATTER_DELIM:
237
+ end_idx = i
238
+ break
239
+ if end_idx is None:
240
+ raise OKFParseError("Unterminated YAML frontmatter block")
241
+
242
+ fm_text = "\n".join(lines[1:end_idx])
243
+ try:
244
+ fm = yaml.safe_load(fm_text) or {}
245
+ except yaml.YAMLError as e:
246
+ raise OKFParseError(f"Invalid YAML in frontmatter: {e}") from e
247
+ if not isinstance(fm, dict):
248
+ raise OKFParseError("Frontmatter must be a YAML mapping")
249
+
250
+ body = "\n".join(lines[end_idx + 1 :])
251
+ if body.startswith("\n"):
252
+ body = body[1:]
253
+ return fm, body
254
+
255
+
256
+ def is_reserved(path: Path) -> bool:
257
+ """Check if a filename is an OKF reserved name (index.md, log.md)."""
258
+ return path.name in _RESERVED_FILENAMES
259
+
260
+
261
+ def path_to_concept_id(bundle_root: Path, path: Path) -> str:
262
+ """Convert a file path to a slash-separated concept ID."""
263
+ rel = path.relative_to(bundle_root).with_suffix("")
264
+ return "/".join(rel.parts)
265
+
266
+
267
+ def parse_bundle(bundle_root: Path) -> list[OKFConcept]:
268
+ """Walk an OKF bundle directory and parse all concept documents.
269
+
270
+ Skips reserved files (``index.md``, ``log.md``) per the spec.
271
+ """
272
+ root = Path(bundle_root)
273
+ concepts: list[OKFConcept] = []
274
+
275
+ for md_file in sorted(root.rglob("*.md")):
276
+ if is_reserved(md_file):
277
+ continue
278
+ text = md_file.read_text(encoding="utf-8")
279
+ fm, body = parse_frontmatter(text)
280
+ concept_id = path_to_concept_id(root, md_file)
281
+ concepts.append(
282
+ OKFConcept(
283
+ concept_id=concept_id,
284
+ frontmatter=fm,
285
+ body=body,
286
+ source_path=str(md_file),
287
+ )
288
+ )
289
+ return concepts
@@ -0,0 +1,189 @@
1
+ """OKF Bundle Document Loader for LangChain — OKF v0.2.
2
+
3
+ Loads an OKF v0.2 knowledge bundle into LangChain ``Document`` objects,
4
+ mapping frontmatter fields to metadata and preserving concept identity.
5
+
6
+ v0.2 additions: ``generated``, ``verified``, ``sources``, ``status``,
7
+ ``stale_after``, and ``trust_tier`` are mapped to Document metadata so
8
+ retrievers can filter on them.
9
+
10
+ See the `OKF spec <https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md>`_
11
+ for format details.
12
+
13
+ Example:
14
+ .. code-block:: python
15
+
16
+ from langchain_okf import OKFBundleLoader
17
+
18
+ loader = OKFBundleLoader("path/to/bundle/")
19
+ docs = loader.load()
20
+
21
+ # Each doc has OKF v0.2 frontmatter as metadata
22
+ docs[0].metadata["type"] # "BigQuery Table"
23
+ docs[0].metadata["concept_id"] # "tables/orders"
24
+ docs[0].metadata["tags"] # ["sales", "orders"]
25
+ docs[0].metadata["trust_tier"] # "human-reviewed"
26
+ docs[0].metadata["status"] # "stable"
27
+ docs[0].metadata["generated_at"] # "2026-06-20T22:53:05Z"
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from collections.abc import Iterator
34
+ from pathlib import Path
35
+
36
+ from langchain_core.document_loaders import BaseLoader
37
+ from langchain_core.documents import Document
38
+
39
+ from langchain_okf._parser import OKFConcept, parse_bundle
40
+
41
+
42
+ class OKFBundleLoader(BaseLoader):
43
+ """Load an OKF v0.2 knowledge bundle as LangChain Documents.
44
+
45
+ Each concept document in the bundle becomes one ``Document``, with
46
+ YAML frontmatter fields mapped to ``Document.metadata``. The
47
+ concept's markdown body becomes ``page_content``.
48
+
49
+ v0.2 metadata fields included in every document:
50
+ - ``trust_tier``: ``"human-reviewed"`` | ``"machine-confirmed"`` | ``"unverified"``
51
+ - ``status``: ``"draft"`` | ``"stable"`` | ``"deprecated"``
52
+ - ``stale_after``: ISO 8601 datetime (if set)
53
+ - ``generated_at`` / ``generated_by``: authorship info
54
+ - ``sources``: list of provenance entries
55
+
56
+ Args:
57
+ bundle_path: Path to the OKF bundle root directory.
58
+ include_types: If set, only load concepts matching these types.
59
+ exclude_types: If set, skip concepts matching these types.
60
+ include_tags: If set, only load concepts having at least one
61
+ of these tags.
62
+ include_status: If set, only load concepts with this status.
63
+ Common values: ``"stable"``, ``"draft"``, ``"deprecated"``.
64
+ exclude_stale: If True (default), exclude concepts whose
65
+ ``stale_after`` has passed.
66
+
67
+ Example:
68
+ .. code-block:: python
69
+
70
+ from langchain_okf import OKFBundleLoader
71
+
72
+ loader = OKFBundleLoader(
73
+ "./bundles/ga4/",
74
+ include_status="stable",
75
+ exclude_stale=True,
76
+ )
77
+ docs = loader.load()
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ bundle_path: str | Path,
83
+ *,
84
+ include_types: list[str] | None = None,
85
+ exclude_types: list[str] | None = None,
86
+ include_tags: list[str] | None = None,
87
+ include_status: str | None = None,
88
+ exclude_stale: bool = True,
89
+ ) -> None:
90
+ self.bundle_path = Path(bundle_path)
91
+ self.include_types = set(include_types) if include_types else None
92
+ self.exclude_types = set(exclude_types) if exclude_types else None
93
+ self.include_tags = set(include_tags) if include_tags else None
94
+ self.include_status = include_status
95
+ self.exclude_stale = exclude_stale
96
+
97
+ def _should_include(self, concept: OKFConcept) -> bool:
98
+ if self.include_types and concept.type not in self.include_types:
99
+ return False
100
+ if self.exclude_types and concept.type in self.exclude_types:
101
+ return False
102
+ if self.include_tags and not (set(concept.tags) & self.include_tags):
103
+ return False
104
+ if self.include_status and concept.status != self.include_status:
105
+ return False
106
+ return not (self.exclude_stale and concept.is_stale)
107
+
108
+ def _concept_to_document(self, concept: OKFConcept) -> Document:
109
+ metadata: dict = {
110
+ "concept_id": concept.concept_id,
111
+ "source": concept.source_path,
112
+ }
113
+
114
+ # ── Core fields ───────────────────────────────────────────────────────
115
+ if concept.type:
116
+ metadata["type"] = concept.type
117
+ if concept.title:
118
+ metadata["title"] = concept.title
119
+ if concept.description:
120
+ metadata["description"] = concept.description
121
+ if concept.resource:
122
+ metadata["resource"] = concept.resource
123
+ if concept.tags:
124
+ metadata["tags"] = concept.tags
125
+
126
+ # ── v0.2 trust ────────────────────────────────────────────────────────
127
+ metadata["trust_tier"] = concept.trust_tier
128
+ metadata["status"] = concept.status
129
+ if concept.stale_after:
130
+ metadata["stale_after"] = concept.stale_after
131
+ if concept.generated_at:
132
+ metadata["generated_at"] = concept.generated_at
133
+ if concept.generated_by:
134
+ metadata["generated_by"] = concept.generated_by
135
+
136
+ # ── v0.2 provenance ───────────────────────────────────────────────────
137
+ if concept.sources:
138
+ # Store as JSON string so vector stores (e.g. Chroma) accept it
139
+ metadata["sources"] = json.dumps(concept.sources, default=str)
140
+ first_resource = concept.sources[0].get("resource", "")
141
+ if first_resource:
142
+ metadata["primary_source"] = first_resource
143
+
144
+ # ── v0.1 compat ───────────────────────────────────────────────────────
145
+ if concept.timestamp:
146
+ metadata["timestamp"] = concept.timestamp
147
+
148
+ # ── Cross-links ───────────────────────────────────────────────────────
149
+ cross_links = concept.cross_links()
150
+ if cross_links:
151
+ metadata["cross_links"] = cross_links
152
+
153
+ # ── Attested Computation ──────────────────────────────────────────────
154
+ if concept.runtime:
155
+ metadata["runtime"] = concept.runtime
156
+
157
+ # ── Remaining frontmatter (extension fields) ──────────────────────────
158
+ skip = {
159
+ "type",
160
+ "title",
161
+ "description",
162
+ "resource",
163
+ "tags",
164
+ "generated",
165
+ "verified",
166
+ "sources",
167
+ "status",
168
+ "stale_after",
169
+ "timestamp",
170
+ "runtime",
171
+ "parameters",
172
+ "executor",
173
+ "attester",
174
+ }
175
+ for key, value in concept.frontmatter.items():
176
+ if key not in skip and key not in metadata:
177
+ metadata[key] = value
178
+
179
+ return Document(page_content=concept.body, metadata=metadata)
180
+
181
+ def lazy_load(self) -> Iterator[Document]:
182
+ """Lazily load OKF concepts as Documents.
183
+
184
+ Yields:
185
+ One ``Document`` per concept, with frontmatter as metadata.
186
+ """
187
+ for concept in parse_bundle(self.bundle_path):
188
+ if self._should_include(concept):
189
+ yield self._concept_to_document(concept)