moca-reader-langchain 0.6.0__tar.gz
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.
- moca_reader_langchain-0.6.0/.gitignore +8 -0
- moca_reader_langchain-0.6.0/PKG-INFO +67 -0
- moca_reader_langchain-0.6.0/README.md +50 -0
- moca_reader_langchain-0.6.0/pyproject.toml +25 -0
- moca_reader_langchain-0.6.0/src/moca_reader_langchain/__init__.py +67 -0
- moca_reader_langchain-0.6.0/src/moca_reader_langchain/py.typed +0 -0
- moca_reader_langchain-0.6.0/tests/conftest.py +31 -0
- moca_reader_langchain-0.6.0/tests/test_langchain_retriever.py +65 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: moca-reader-langchain
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: A LangChain retriever over MOCA packages: citation records as Documents, through the MOCA Reader's gate.
|
|
5
|
+
Project-URL: Homepage, https://openmoca.org
|
|
6
|
+
Project-URL: Source, https://github.com/openmoca/moca-reader-python
|
|
7
|
+
Author: MOCA Standard Contributors
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: citations,langchain,moca,rag,retriever
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: langchain-core>=1.0
|
|
15
|
+
Requires-Dist: moca-reader==0.6.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# moca-reader-langchain
|
|
19
|
+
|
|
20
|
+
A [LangChain](https://python.langchain.com) retriever over MOCA packages,
|
|
21
|
+
built on the [MOCA Reader for Python](https://github.com/openmoca/moca-reader-python).
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install moca-reader-langchain
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from moca_reader import LexicalBackend, Library, Search, read_package
|
|
29
|
+
from moca_reader_langchain import MocaRetriever
|
|
30
|
+
|
|
31
|
+
pkg = read_package("support-kb.moca", trust_root="trust-root.json")
|
|
32
|
+
library = Library().add(pkg)
|
|
33
|
+
search = Search(library, LexicalBackend(library), audiences=["public"])
|
|
34
|
+
|
|
35
|
+
retriever = MocaRetriever(search=search, limit=3)
|
|
36
|
+
for doc in retriever.invoke("How long is the refund window?"):
|
|
37
|
+
print(doc.metadata["moca_node_ref"], doc.metadata["moca_score"])
|
|
38
|
+
print(doc.page_content[:80])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`MocaRetriever` wraps the `Search` you build: its backend (lexical, dense,
|
|
42
|
+
hybrid or your own store), its audiences and its hooks. Every call goes through
|
|
43
|
+
the Reader's gate, so content that is out of force, superseded, deprecated or
|
|
44
|
+
outside your audiences never comes back, and nothing a caller passes can widen
|
|
45
|
+
that. `limit`, `locale`, `concepts`, `scope` and `include_all` are set when you
|
|
46
|
+
build the retriever.
|
|
47
|
+
|
|
48
|
+
Each result is a LangChain `Document`:
|
|
49
|
+
|
|
50
|
+
| Field | Holds |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `page_content` | The text of the node, or of a sidecar chunk |
|
|
53
|
+
| `id` | The node's versioned reference and byte span |
|
|
54
|
+
| `metadata["moca_citation"]` | The whole citation record: package, version and digest, trust signals, evidence |
|
|
55
|
+
| `metadata["moca_*"]` | The flat fields of the consuming guide (`moca_node_ref`, `moca_package_digest`, `moca_status`, `moca_signed`, ...), for vector stores that need flat metadata |
|
|
56
|
+
| `metadata["moca_score"]` | The search score |
|
|
57
|
+
|
|
58
|
+
**Package text is untrusted input.** Put `page_content` in a prompt as quoted
|
|
59
|
+
reference material to cite, never as instructions
|
|
60
|
+
([Reader contract §12](https://github.com/openmoca/moca-spec/blob/main/spec/moca-reader-contract.md#12-handing-content-to-a-model)).
|
|
61
|
+
Show users the source (`moca_node_ref`, the package version) with each answer.
|
|
62
|
+
|
|
63
|
+
The async path (`ainvoke`) runs the same search.
|
|
64
|
+
|
|
65
|
+
## Licence
|
|
66
|
+
|
|
67
|
+
Apache-2.0.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# moca-reader-langchain
|
|
2
|
+
|
|
3
|
+
A [LangChain](https://python.langchain.com) retriever over MOCA packages,
|
|
4
|
+
built on the [MOCA Reader for Python](https://github.com/openmoca/moca-reader-python).
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install moca-reader-langchain
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from moca_reader import LexicalBackend, Library, Search, read_package
|
|
12
|
+
from moca_reader_langchain import MocaRetriever
|
|
13
|
+
|
|
14
|
+
pkg = read_package("support-kb.moca", trust_root="trust-root.json")
|
|
15
|
+
library = Library().add(pkg)
|
|
16
|
+
search = Search(library, LexicalBackend(library), audiences=["public"])
|
|
17
|
+
|
|
18
|
+
retriever = MocaRetriever(search=search, limit=3)
|
|
19
|
+
for doc in retriever.invoke("How long is the refund window?"):
|
|
20
|
+
print(doc.metadata["moca_node_ref"], doc.metadata["moca_score"])
|
|
21
|
+
print(doc.page_content[:80])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`MocaRetriever` wraps the `Search` you build: its backend (lexical, dense,
|
|
25
|
+
hybrid or your own store), its audiences and its hooks. Every call goes through
|
|
26
|
+
the Reader's gate, so content that is out of force, superseded, deprecated or
|
|
27
|
+
outside your audiences never comes back, and nothing a caller passes can widen
|
|
28
|
+
that. `limit`, `locale`, `concepts`, `scope` and `include_all` are set when you
|
|
29
|
+
build the retriever.
|
|
30
|
+
|
|
31
|
+
Each result is a LangChain `Document`:
|
|
32
|
+
|
|
33
|
+
| Field | Holds |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `page_content` | The text of the node, or of a sidecar chunk |
|
|
36
|
+
| `id` | The node's versioned reference and byte span |
|
|
37
|
+
| `metadata["moca_citation"]` | The whole citation record: package, version and digest, trust signals, evidence |
|
|
38
|
+
| `metadata["moca_*"]` | The flat fields of the consuming guide (`moca_node_ref`, `moca_package_digest`, `moca_status`, `moca_signed`, ...), for vector stores that need flat metadata |
|
|
39
|
+
| `metadata["moca_score"]` | The search score |
|
|
40
|
+
|
|
41
|
+
**Package text is untrusted input.** Put `page_content` in a prompt as quoted
|
|
42
|
+
reference material to cite, never as instructions
|
|
43
|
+
([Reader contract §12](https://github.com/openmoca/moca-spec/blob/main/spec/moca-reader-contract.md#12-handing-content-to-a-model)).
|
|
44
|
+
Show users the source (`moca_node_ref`, the package version) with each answer.
|
|
45
|
+
|
|
46
|
+
The async path (`ainvoke`) runs the same search.
|
|
47
|
+
|
|
48
|
+
## Licence
|
|
49
|
+
|
|
50
|
+
Apache-2.0.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moca-reader-langchain"
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "A LangChain retriever over MOCA packages: citation records as Documents, through the MOCA Reader's gate."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
authors = [{ name = "MOCA Standard Contributors" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["moca-reader==0.6.0", "langchain-core>=1.0"]
|
|
14
|
+
keywords = ["moca", "langchain", "retriever", "rag", "citations"]
|
|
15
|
+
classifiers = ["Development Status :: 4 - Beta", "Programming Language :: Python :: 3", "Typing :: Typed"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://openmoca.org"
|
|
19
|
+
Source = "https://github.com/openmoca/moca-reader-python"
|
|
20
|
+
|
|
21
|
+
[tool.uv.sources]
|
|
22
|
+
moca-reader = { workspace = true }
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/moca_reader_langchain"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""A LangChain retriever over MOCA packages (Reader interface §7).
|
|
2
|
+
|
|
3
|
+
``MocaRetriever`` wraps a host-built ``moca_reader.Search``: its backend, audiences and hooks. Every call goes
|
|
4
|
+
through the Reader's gate, so the retrieval policy and the host's audience filter always apply, and nothing a
|
|
5
|
+
caller passes can widen them. Each citation record becomes a ``Document``: the text as ``page_content``, the
|
|
6
|
+
whole record under ``metadata["moca_citation"]``, and the consuming guide's flat ``moca_*`` fields beside it.
|
|
7
|
+
|
|
8
|
+
Package text is untrusted input to a model: pass ``page_content`` to a model as quoted reference material to cite,
|
|
9
|
+
never as instructions (Reader contract §12).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
|
17
|
+
from langchain_core.documents import Document
|
|
18
|
+
from langchain_core.retrievers import BaseRetriever
|
|
19
|
+
from pydantic import ConfigDict
|
|
20
|
+
|
|
21
|
+
from moca_reader import CitationRecord, Search, flat_metadata
|
|
22
|
+
|
|
23
|
+
__all__ = ["MocaRetriever", "document_id", "to_document"]
|
|
24
|
+
__version__ = "0.6.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def document_id(record: CitationRecord) -> str:
|
|
28
|
+
"""A unique id: the node's versioned reference, then the byte span, since one node can return several chunks."""
|
|
29
|
+
span = record.get("span")
|
|
30
|
+
ref = str(record["node"]["ref"])
|
|
31
|
+
return f"{ref}:{span['start']}-{span['end']}" if isinstance(span, dict) else ref
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def to_document(record: CitationRecord) -> Document:
|
|
35
|
+
"""A citation record as a LangChain ``Document``."""
|
|
36
|
+
metadata: dict[str, Any] = {"moca_citation": record, **flat_metadata(record)}
|
|
37
|
+
if isinstance(record.get("score"), (int, float)):
|
|
38
|
+
metadata["moca_score"] = record["score"]
|
|
39
|
+
return Document(page_content=record.get("text") or "", id=document_id(record), metadata=metadata)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MocaRetriever(BaseRetriever):
|
|
43
|
+
"""Retrieves citation records from MOCA packages as LangChain ``Document``s.
|
|
44
|
+
|
|
45
|
+
``search`` is the host's ``moca_reader.Search``. The other fields are the search options for every call; they
|
|
46
|
+
are the host's, set when the retriever is built.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
50
|
+
|
|
51
|
+
search: Search
|
|
52
|
+
limit: int = 5
|
|
53
|
+
locale: str | None = None
|
|
54
|
+
concepts: list[str] | None = None
|
|
55
|
+
scope: str | None = None
|
|
56
|
+
include_all: bool = False
|
|
57
|
+
|
|
58
|
+
def _get_relevant_documents(self, query: str, *, run_manager: CallbackManagerForRetrieverRun) -> list[Document]:
|
|
59
|
+
records = self.search.search(
|
|
60
|
+
query,
|
|
61
|
+
limit=self.limit,
|
|
62
|
+
include_all=self.include_all,
|
|
63
|
+
locale=self.locale,
|
|
64
|
+
concepts=self.concepts,
|
|
65
|
+
scope=self.scope,
|
|
66
|
+
)
|
|
67
|
+
return [to_document(r) for r in records]
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Fixtures over moca-spec's support-kb: ``MOCA_SPEC_DIR``, else ``moca-spec`` beside this repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from moca_reader import LexicalBackend, Library, Search, read_package
|
|
11
|
+
|
|
12
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
13
|
+
MOCA_SPEC = Path(os.environ.get("MOCA_SPEC_DIR") or REPO.parent / "moca-spec").resolve()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def moca_spec() -> Path:
|
|
18
|
+
return MOCA_SPEC
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def library() -> Library:
|
|
23
|
+
pkg = read_package(
|
|
24
|
+
MOCA_SPEC / "examples" / "support-kb", trust_root=MOCA_SPEC / "fixtures" / "signing-keys" / "trust-root.json"
|
|
25
|
+
)
|
|
26
|
+
return Library().add(pkg)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def public_search(library: Library) -> Search:
|
|
31
|
+
return Search(library, LexicalBackend(library), audiences=["public"])
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""MocaRetriever returns citation records as LangChain Documents, through the Reader's gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from moca_reader_langchain import MocaRetriever
|
|
9
|
+
|
|
10
|
+
from moca_reader import FLAT_FIELDS, Library, MemoryStoreBackend, Search, StoreScope, package_state
|
|
11
|
+
from moca_reader.schemas import validate_against
|
|
12
|
+
|
|
13
|
+
REFUND = "https://example.com/moca/support-kb@4.2.0#refund-window.md"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_documents_carry_text_id_score_and_the_whole_record(public_search: Search) -> None:
|
|
17
|
+
docs = MocaRetriever(search=public_search, limit=3).invoke("How long is the refund window?")
|
|
18
|
+
first = docs[0]
|
|
19
|
+
assert first.metadata["moca_node_ref"] == REFUND
|
|
20
|
+
assert first.id is not None and first.id.startswith(REFUND)
|
|
21
|
+
assert "30 days of delivery" in first.page_content
|
|
22
|
+
assert isinstance(first.metadata["moca_score"], float)
|
|
23
|
+
record = first.metadata["moca_citation"]
|
|
24
|
+
assert validate_against("citationRecord", json.loads(json.dumps(record))) == []
|
|
25
|
+
assert set(first.metadata) - {"moca_citation", "moca_score"} <= set(FLAT_FIELDS)
|
|
26
|
+
assert (first.metadata["moca_signed"], first.metadata["moca_attested_reviews"]) == (True, 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_the_hosts_audience_filter_holds_whatever_is_asked(public_search: Search) -> None:
|
|
30
|
+
docs = MocaRetriever(search=public_search, limit=20, include_all=True).invoke("delete my account")
|
|
31
|
+
assert all(d.metadata.get("moca_audience") != "internal" for d in docs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_a_store_backend_works_through_the_same_retriever(library: Library) -> None:
|
|
35
|
+
store = MemoryStoreBackend().ingest(library)
|
|
36
|
+
search = Search(StoreScope([package_state(p.result) for p in library.packages], library.clock), store)
|
|
37
|
+
docs = MocaRetriever(search=search, limit=1).invoke("domestic express shipping")
|
|
38
|
+
assert docs[0].metadata["moca_node_ref"].endswith("#shipping-estimates.md")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_async_retrieval_returns_the_same(public_search: Search) -> None:
|
|
42
|
+
import asyncio
|
|
43
|
+
|
|
44
|
+
retriever = MocaRetriever(search=public_search, limit=2)
|
|
45
|
+
sync = [d.id for d in retriever.invoke("refund window")]
|
|
46
|
+
asynchronous = [d.id for d in asyncio.run(retriever.ainvoke("refund window"))]
|
|
47
|
+
assert sync == asynchronous
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_the_readme_example_runs(moca_spec: Path, monkeypatch: object) -> None:
|
|
51
|
+
readme = (Path(__file__).resolve().parents[1] / "README.md").read_text("utf-8")
|
|
52
|
+
code = readme.split("```python\n", 1)[1].split("```", 1)[0]
|
|
53
|
+
import os
|
|
54
|
+
|
|
55
|
+
os.chdir(moca_spec / "examples")
|
|
56
|
+
exec(
|
|
57
|
+
compile(
|
|
58
|
+
code.replace('"support-kb.moca"', '"support-kb"').replace(
|
|
59
|
+
'"trust-root.json"', '"../fixtures/signing-keys/trust-root.json"'
|
|
60
|
+
),
|
|
61
|
+
"README",
|
|
62
|
+
"exec",
|
|
63
|
+
),
|
|
64
|
+
{},
|
|
65
|
+
)
|