Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ serve-web:
##################################################

# Code quality checks
.PHONY: format lint static-check
.PHONY: format lint static-check add-license
format:
uvx ruff check --fix ./aperag ./tests
uvx ruff format ./aperag ./tests
Expand All @@ -197,6 +197,9 @@ lint:
static-check:
uvx mypy ./aperag

add-license:
@echo "License headers are maintained in source files."

# Testing suite
.PHONY: test-all test-unit test-integration test-e2e test-e2e-perf \
test-http-bootstrap test-http-smoke test-http-full \
Expand Down
23 changes: 7 additions & 16 deletions aperag/cache/invalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,13 @@ async def invalidate_document(
) -> None:
"""Purge cached entries that pin to a specific document.

Removes ``d10:outline:{document_id}:*``,
``d10:section:{document_id}:*`` and ``d10:content:{document_id}:*``
from both L1 and L2.

NOTE: ``d10:chunk:*`` is keyed by ``chunk_id`` (not ``document_id``),
so a per-document invalidation does **not** purge chunk entries.
Callers that delete a document and need chunk-level invalidation
must walk the document's chunk list and invalidate each chunk_id
separately. This is intentional — keeps the chunk namespace
indexing-immutable per §E.6 and avoids requiring the cache to know
chunk-to-document mapping.
Removes the read primitive namespaces from both L1 and L2. The
cache API currently supports namespace-level deletion rather than
prefix deletion, so this intentionally over-purges to avoid stale
reads after document removal or index rebuild.
"""

namespaces = ("outline", "section", "content")
namespaces = ("outline", "section", "content", "chunk")
for ns in namespaces:
# The current cache stores a flat key per ``(namespace,
# document_id, parse_version, ...)`` so we cannot pinpoint a
Expand All @@ -89,12 +82,10 @@ async def invalidate_collection(
Same conservative-over-purge semantics as
:func:`invalidate_document` — the cache layer doesn't know which
documents belong to which collection without an external mapping,
so on D11+ write-tool calls we simply purge the parse-version-bound
namespaces. ``d10:chunk:*`` again is not affected (chunk_id is
indexing-immutable).
so on D11+ write-tool calls we simply purge every read namespace.
"""

namespaces = ("outline", "section", "content")
namespaces = ("outline", "section", "content", "chunk")
for ns in namespaces:
await cache.l1.delete_namespace(ns)
await cache.l2.delete_namespace(ns)
Expand Down
9 changes: 5 additions & 4 deletions aperag/cache/read_primitive_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,11 @@ async def get_or_compute_outline(
*,
document_id: str,
parse_version: str,
max_depth: int = 6,
compute: Callable[[], Awaitable[ModelT]],
model_cls: type[ModelT],
) -> ModelT:
key = _build_key(NAMESPACE_OUTLINE, document_id, parse_version)
key = _build_key(NAMESPACE_OUTLINE, document_id, parse_version, str(int(max_depth)))
return await self._get_or_compute(key=key, compute=compute, model_cls=model_cls)

async def get_or_compute_section(
Expand Down Expand Up @@ -177,13 +178,13 @@ async def get_or_compute_content(
async def get_or_compute_chunk(
self,
*,
collection_id: str,
document_id: str,
chunk_id: str,
compute: Callable[[], Awaitable[ModelT]],
model_cls: type[ModelT],
) -> ModelT:
# §E.6: chunk_id is indexing-layer-immutable, so there is no
# parse_version dimension on this namespace.
key = _build_key(NAMESPACE_CHUNK, chunk_id)
key = _build_key(NAMESPACE_CHUNK, collection_id, document_id, chunk_id)
return await self._get_or_compute(key=key, compute=compute, model_cls=model_cls)

# ------------------------------------------------------------------
Expand Down
12 changes: 4 additions & 8 deletions aperag/mcp/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
``list_tools`` metadata for external Agents (Claude Code / Codex /
Cursor).

* :data:`KNOWN_CAPABILITIES` — the closed set of capability keys that
D10 currently uses (``vision``, ``long_context``, ``graph_index``,
``fulltext_index``, ``web_access``). Adding a new capability key
requires a ``[D10 spec amendment]`` thread per §G hard gate.
* :data:`KNOWN_CAPABILITIES` — the closed set of client/runtime
capability keys that D10 currently uses (``vision``,
``long_context``, ``web_access``). Collection index availability is
collection state, not a client capability.

The actual registry of tool name → annotation lives in
:mod:`aperag.mcp.tools._annotations`. Server-side filtering (Option B)
Expand All @@ -50,8 +50,6 @@
{
"vision", # client can render / reason over images
"long_context", # client tolerates large content payloads
"graph_index", # collection has a graph index built
"fulltext_index", # collection has a fulltext index built
"web_access", # caller may reach the public internet
}
)
Expand Down Expand Up @@ -81,8 +79,6 @@ class ToolAnnotation(BaseModel):
"capabilities": {
"vision": False,
"long_context": False,
"graph_index": True,
"fulltext_index": True,
"web_access": True,
},
"deprecated": False,
Expand Down
15 changes: 12 additions & 3 deletions aperag/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@
# Initialize FastMCP server
mcp_server = FastMCP("ApeRAG")

# Base URL for internal API calls
API_BASE_URL = "http://localhost:8000"
# Base URL for internal API calls. Deployments can point the MCP server
# at a colocated API service without changing the public tool surface.
API_BASE_URL = os.getenv("APERAG_API_BASE_URL", "http://localhost:8000").rstrip("/")


# === D10.c read primitives ===
Expand Down Expand Up @@ -298,7 +299,15 @@ async def read_document_chunk(
# in the same cutover.


@mcp_server.tool
@mcp_server.tool(
annotations=_register_tool_annotation(
"web_read",
ToolAnnotation(
requires=("web_access",),
capabilities={"long_context": False, "web_access": True},
),
),
)
async def web_read(
url_list: list[str],
timeout: int = 30,
Expand Down
7 changes: 4 additions & 3 deletions aperag/mcp/tools/read_document_chunk.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,8 @@ async def read_document_chunk(
parse_version = resolve_parse_version(document, collection)

# 5. Fetch authoritative chunk — D10.g cache-wrapped.
# §E.6: chunk_id is indexing-immutable, so the cache key is
# ``(chunk_id,)`` only — no parse_version weighting. Tenancy/auth
# above are NEVER skipped (§E.7 hard lock).
# The key includes collection and document scope because chunk ids
# are not guaranteed globally unique across all tenants.
cache = await get_read_primitive_cache()

async def _compute() -> DocumentChunk:
Expand Down Expand Up @@ -117,6 +116,8 @@ async def _compute() -> DocumentChunk:
)

return await cache.get_or_compute_chunk(
collection_id=collection_id,
document_id=document_id,
chunk_id=chunk_id,
compute=_compute,
model_cls=DocumentChunk,
Expand Down
1 change: 1 addition & 0 deletions aperag/mcp/tools/read_document_outline.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ async def _compute() -> DocumentOutline:
return await cache.get_or_compute_outline(
document_id=document.id,
parse_version=parse_version,
max_depth=max_depth,
compute=_compute,
model_cls=DocumentOutline,
)
Expand Down
23 changes: 4 additions & 19 deletions aperag/mcp/tools/search_fulltext.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Phase 9 D10.d (#96) — fulltext_search split MCP tool (§B.3).
"""Full-text search MCP tool.

The discrete keyword / full-text search primitive per
``docs/modularization/d10-design-pack.md`` §B.3 (Lock #5 split). The
Expand All @@ -25,11 +25,8 @@
``section_path`` / ``heading_anchor`` so callers can chain into the
read primitives.

The ``cursor`` parameter is a placeholder: the signature lands now
so external clients see the canonical shape, but the body raises
``NotImplementedError`` on any non-empty value until real search
pagination ships. ``None`` and ``""`` both preserve single-page
``top_k`` behavior.
Search pagination is not part of the public MCP contract yet, so this
tool intentionally exposes only single-page ``top_k`` retrieval.
"""

from __future__ import annotations
Expand All @@ -52,9 +49,7 @@
"fulltext_search",
ToolAnnotation(
requires=("collection_access",),
# fulltext_search needs the inverted index — explicit-not-silent
# per §D.3 if the collection has no fulltext index.
capabilities={"long_context": False, "fulltext_index": True},
capabilities={"long_context": False},
),
),
)
Expand All @@ -65,7 +60,6 @@ async def fulltext_search(
top_k: int = 5,
keywords: list[str] | None = None,
rerank: bool = True,
cursor: str | None = None,
) -> Dict[str, Any]:
"""Full-text keyword search within a collection (§B.3).

Expand Down Expand Up @@ -100,21 +94,12 @@ async def fulltext_search(
auto-extracted keywords from the query.
rerank: Whether to apply reranker on returned candidates
(default: True).
cursor: Pagination cursor placeholder (§B.3 / amendment
msg=b9b7072a Drift #4 (c)). ``None`` and ``""`` return
first page; any non-empty value raises
``NotImplementedError`` with a clear "not implemented"
message until real search pagination ships.

Returns:
Search results with ``items`` carrying ``recall_type =
"fulltext_search"``. Highlight snippets / matched terms are
surfaced via ``items[*].metadata``.
"""
if cursor:
raise NotImplementedError(
"search pagination is not yet implemented (tool=fulltext_search, reason=search_not_paginated)"
)
try:
api_key = get_api_key()

Expand Down
23 changes: 4 additions & 19 deletions aperag/mcp/tools/search_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Phase 9 D10.d (#96) — graph_search split MCP tool (§B.2).
"""Graph search MCP tool.

The discrete knowledge-graph search primitive per
``docs/modularization/d10-design-pack.md`` §B.2 (Lock #5 split). The
Expand All @@ -25,11 +25,8 @@
``section_path`` / ``heading_anchor`` so callers can chain into the
read primitives.

The ``cursor`` parameter is a placeholder: the signature lands now
so external clients see the canonical shape, but the body raises
``NotImplementedError`` on any non-empty value until real search
pagination ships. ``None`` and ``""`` both preserve single-page
``top_k`` behavior.
Search pagination is not part of the public MCP contract yet, so this
tool intentionally exposes only single-page ``top_k`` retrieval.
"""

from __future__ import annotations
Expand All @@ -52,9 +49,7 @@
"graph_search",
ToolAnnotation(
requires=("collection_access",),
# graph_search returns nothing useful unless the collection
# has a graph index built — explicit-not-silent per §D.3.
capabilities={"long_context": False, "graph_index": True},
capabilities={"long_context": False},
),
),
)
Expand All @@ -63,7 +58,6 @@ async def graph_search(
query: str,
*,
top_k: int = 5,
cursor: str | None = None,
) -> Dict[str, Any]:
"""Knowledge-graph search within a collection (§B.2).

Expand Down Expand Up @@ -95,21 +89,12 @@ async def graph_search(
collection_id: The ID of the collection to search.
query: The natural-language search query.
top_k: Maximum number of results to return (default: 5).
cursor: Pagination cursor placeholder (§B.2 / amendment
msg=b9b7072a Drift #4 (c)). ``None`` and ``""`` return
first page; any non-empty value raises
``NotImplementedError`` with a clear "not implemented"
message until real search pagination ships.

Returns:
Search results with ``items`` carrying ``recall_type =
"graph_search"``. Graph-specific fields (entity / relation / path)
are surfaced via ``items[*].metadata``.
"""
if cursor:
raise NotImplementedError(
"search pagination is not yet implemented (tool=graph_search, reason=search_not_paginated)"
)
try:
api_key = get_api_key()

Expand Down
32 changes: 3 additions & 29 deletions aperag/mcp/tools/search_vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Phase 9 D10.d (#96) — vector_search split MCP tool (§B.1).
"""Vector search MCP tool.

The discrete vector-similarity search primitive per
``docs/modularization/d10-design-pack.md`` §B.1 (Lock #5 split). The
Expand All @@ -27,18 +27,8 @@
caller can navigate from a search hit straight into the read
primitives.

The ``cursor`` parameter is a placeholder per the same amendment
thread (Drift #4 resolution (c)): the signature is published now so
external MCP clients see the canonical shape, but the body explicitly
raises ``NotImplementedError("search pagination is not yet
implemented")`` on any **non-empty** value. The architect sign-off
(msg=ebfcdabe) plus Weston's blocker review (msg=177a1dd8) explicitly
forbid reusing the canonical ``CursorError("cursor_invalid", ...)``
malformed-wire semantic for this "feature not implemented" case —
that would camouflage missing capability as a client-side cursor bug.
``cursor=None`` and ``cursor=""`` both keep the existing single-page
``top_k`` behavior (Weston msg=177a1dd8 lock); only truthy /
non-empty cursor values trigger the loud-fail.
Search pagination is not part of the public MCP contract yet, so this
tool intentionally exposes only single-page ``top_k`` retrieval.
"""

from __future__ import annotations
Expand Down Expand Up @@ -72,7 +62,6 @@ async def vector_search(
top_k: int = 5,
similarity_threshold: float | None = None,
rerank: bool = True,
cursor: str | None = None,
) -> Dict[str, Any]:
"""Vector similarity search within a collection (§B.1).

Expand Down Expand Up @@ -107,26 +96,11 @@ async def vector_search(
similarity_threshold: Minimum similarity score [0, 1]; ``None``
uses the collection's default threshold.
rerank: Whether to apply reranker on returned candidates (default: True).
cursor: Pagination cursor placeholder (§B.1). ``None`` and
``""`` both return the first page (current behavior); any
non-empty value raises ``NotImplementedError`` with a
``"search pagination is not yet implemented"`` message per
the ``[D10 spec amendment]`` (msg=b9b7072a + sign-off
msg=ebfcdabe + Weston msg=177a1dd8). Real search
pagination requires a backend capability that is not yet
available and will land in a dedicated D11+ upgrade.

Returns:
Search results with ``items`` ranked by vector similarity. Each
item carries ``recall_type = "vector_search"``.
"""
if cursor:
# ``cursor`` is truthy iff non-None and non-empty (Weston
# msg=177a1dd8 lock: ``None`` and ``""`` both preserve
# single-page ``top_k`` behavior).
raise NotImplementedError(
"search pagination is not yet implemented (tool=vector_search, reason=search_not_paginated)"
)
try:
api_key = get_api_key()

Expand Down
5 changes: 2 additions & 3 deletions aperag/sdk/capability_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,8 @@ def filter_tools(
``annotations`` is a name → :class:`ToolAnnotation` map (typically
:func:`aperag.mcp.tools._annotations.get_all`).
``client_capabilities`` is the client's runtime capability map —
e.g. ``{"vision": True, "long_context": True, "graph_index":
True, "fulltext_index": True, "web_access": False}`` for an
air-gapped vision-capable LLM host.
e.g. ``{"vision": True, "long_context": True,
"web_access": False}`` for an air-gapped vision-capable LLM host.

Returns one :class:`FilterDecision` per tool, in the same order as
``annotations`` iteration. Callers wanting just the usable subset
Expand Down
Loading
Loading