Skip to content

Commit 2eb0404

Browse files
authored
Merge pull request #1416 from Open-Source-Legal/claude/resolve-issue-1408-SnImO
Tighten shared protocols + protocol tests (Closes #1408)
2 parents 5f3b453 + 2b31c35 commit 2eb0404

4 files changed

Lines changed: 56 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Shared-protocol contract drift surfaced by PR #1400 follow-up review** (Issue #1408):
13+
- **`PermissionedQueryManagerProtocol.visible_to_user` signature mismatch** (`opencontractserver/types/protocols.py:147`): the protocol declared `visible_to_user(self, user: Any = None)` but `PermissionManager.visible_to_user` and `UserFeedbackManager.visible_to_user` have no default. A caller holding a protocol-typed reference could call `.visible_to_user()` with no args and trigger a runtime `TypeError`, and mypy would also reject the concrete classes as structural matches. Dropped the `= None` default so the protocol pins the strictest contract; the docstring now explains that callers must pass an `AnonymousUser` when no authenticated principal is available. Lenient managers (e.g. `BaseVisibilityManager` with `user=None`) still satisfy the protocol — verified with both `isinstance` and `issubclass` checks.
14+
- **`ToolProtocol` `@property` descriptors against a `@dataclass`** (`opencontractserver/types/protocols.py:97`): `CoreTool` is a `@dataclass` exposing `name` / `description` / `parameters` / `requires_approval` as plain instance attributes. Mypy accepted dataclass fields against the property-shaped protocol, but the asymmetry was a footgun for future implementors who would reach for `@property` based on the protocol surface. Converted to plain attribute declarations matching the concrete class.
15+
- **`StreamObserverProtocol` drift hazard** (`opencontractserver/types/protocols.py:152`): the protocol duplicated `opencontractserver.llms.types.StreamObserver` with no automated enforcement. A naive re-export creates a real circular-import risk because `protocols.py` is imported by `opencontractserver.shared.Managers` during Django app loading and `opencontractserver.llms.__init__` eagerly pulls in the heavy LLM stack (`api` → conversation models, agent factories). Kept duplicated definitions but added explicit "Must be kept in sync with…" notices on both sides (`opencontractserver/llms/types.py:18` and `opencontractserver/types/protocols.py:152`) explaining the rationale, and aligned the `__call__` return type on `llms.types.StreamObserver` (`-> None`, dropping the redundant `Awaitable[None]` wrapper that was already implicit in `async def`).
16+
- **`VectorStoreProtocol` test missed renames** (`opencontractserver/tests/test_protocols.py:24`): the canonical-implementation test used `hasattr` against hard-coded method names instead of the `@runtime_checkable` machinery used by the other three tests in the file. Renaming `search` / `async_search` would have left the test passing as long as the attribute names happened to exist via some other code path. Switched to `issubclass(CoreAnnotationVectorStore, VectorStoreProtocol)`, which executes the protocol's structural check without instantiating the class. The negative test for plain `object` was tightened from a manual `hasattr` to `issubclass(object, VectorStoreProtocol)` for symmetry.
17+
1018
### Added
1119

1220
- **VCR.py wrapper for LLM calls in `doc_extract_query_task`**`opencontractserver/utils/vcr_replay.py` exposes a `maybe_vcr_cassette()` context manager that, when `OC_LLM_VCR_MODE` and `OC_LLM_VCR_CASSETTE` are set on the celery worker, records or replays every HTTP call to LLM provider hosts (currently `api.openai.com` / `api.anthropic.com`). A custom request-body matcher strips volatile values (millisecond timestamps, Django document PKs, OpenAI tool-call IDs, UUIDs) so a cassette recorded against one DB replays cleanly against another. With the env vars unset the wrapper is a no-op — production behavior is unchanged. Pre-recorded cassette for the E2E extract spec lives at `opencontractserver/tests/fixtures/cassettes/e2e_extract_pdf_workflow/extract.yaml`. Replay was verified end-to-end against a deliberately-fake `OPENAI_API_KEY` to confirm no real network call is made. See `docs/development/e2e_vcr.md` for record / replay / debug instructions.

opencontractserver/llms/types.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Shared types and enums for the OpenContracts LLM framework."""
22

3-
from collections.abc import Awaitable
43
from enum import Enum
54
from typing import Any, Protocol
65

@@ -22,6 +21,12 @@ class StreamObserver(Protocol):
2221
Framework adapters will call this observer *whenever* they emit a
2322
chunk, allowing the host application (e.g. WebSocket layer) to forward
2423
nested or cross-agent events in real time.
24+
25+
.. important::
26+
Must be kept in sync with
27+
:class:`opencontractserver.types.protocols.StreamObserverProtocol`,
28+
which mirrors this contract for non-LLM consumers that cannot
29+
import the ``llms`` package without circular-import risk.
2530
"""
2631

27-
async def __call__(self, event: Any) -> Awaitable[None]: ...
32+
async def __call__(self, event: Any) -> None: ...

opencontractserver/tests/test_protocols.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
PermissionedQueryManagerProtocol,
1515
PipelineComponentProtocol,
1616
ToolProtocol,
17+
VectorStoreProtocol,
1718
)
1819

1920

@@ -25,14 +26,13 @@ def test_vector_store_protocol_accepts_core_vector_store(self) -> None:
2526
CoreAnnotationVectorStore,
2627
)
2728

28-
# Don't instantiate (would require a real corpus); class-level
29-
# ``isinstance`` is enough since ``runtime_checkable`` just checks
30-
# for attribute presence. Verify the four required methods exist.
31-
for attr in ("search", "async_search"):
32-
self.assertTrue(
33-
hasattr(CoreAnnotationVectorStore, attr),
34-
f"CoreAnnotationVectorStore missing {attr}",
35-
)
29+
# ``issubclass`` works on ``@runtime_checkable`` protocols
30+
# without instantiating the class (avoids needing a real corpus)
31+
# and matches the style of the other protocol tests below.
32+
# Catches regressions where a method is renamed rather than
33+
# deleted, which a ``hasattr`` loop over hard-coded names would
34+
# miss.
35+
self.assertTrue(issubclass(CoreAnnotationVectorStore, VectorStoreProtocol))
3636

3737
def test_pipeline_component_protocol_accepts_base(self) -> None:
3838
# Validate that a registered concrete component satisfies the
@@ -71,11 +71,9 @@ def test_plain_object_is_not_a_tool(self) -> None:
7171
self.assertNotIsInstance(object(), ToolProtocol)
7272

7373
def test_plain_object_is_not_a_vector_store(self) -> None:
74-
# CoreAnnotationVectorStore has both ``search`` and ``async_search``;
75-
# a bare object has neither.
76-
self.assertFalse(
77-
hasattr(object(), "search") and hasattr(object(), "async_search")
78-
)
74+
# ``object`` has neither ``search`` nor ``async_search``; the
75+
# runtime-checkable protocol must reject it.
76+
self.assertFalse(issubclass(object, VectorStoreProtocol))
7977

8078
def test_plain_object_is_not_a_permissioned_manager(self) -> None:
8179
self.assertNotIsInstance(object(), PermissionedQueryManagerProtocol)

opencontractserver/types/protocols.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -106,27 +106,17 @@ class ToolProtocol(Protocol):
106106
107107
See ``docs/architecture/llms/README.md`` for the
108108
``CoreTool`` → framework-specific wrapper architecture.
109-
"""
110-
111-
@property
112-
def name(self) -> str:
113-
"""Tool name as exposed to the LLM."""
114-
...
115-
116-
@property
117-
def description(self) -> str:
118-
"""Human-readable description used in tool schemas."""
119-
...
120109
121-
@property
122-
def parameters(self) -> dict[str, Any]:
123-
"""JSONSchema-style parameters dict for the tool."""
124-
...
110+
Members are declared as plain attributes (not ``@property``
111+
descriptors) because ``CoreTool`` is a ``@dataclass`` exposing them
112+
as instance attributes. Attribute declarations match the concrete
113+
surface and make the contract obvious to future implementors.
114+
"""
125115

126-
@property
127-
def requires_approval(self) -> bool:
128-
"""``True`` if invocation must be gated behind an approval step."""
129-
...
116+
name: str
117+
description: str
118+
parameters: dict[str, Any]
119+
requires_approval: bool
130120

131121

132122
# ---------------------------------------------------------------------------
@@ -144,13 +134,17 @@ class PermissionedQueryManagerProtocol(Protocol):
144134
"permissioned manager" (e.g. for analytics or graphene resolvers)
145135
should depend on this protocol, not on a concrete manager class.
146136
147-
The argument default is ``None`` so managers that accept anonymous
148-
callers still satisfy the protocol; ``Any`` is used because the
149-
concrete user type varies across managers (``AbstractUser``,
150-
``AnonymousUser``, or a ``Q`` for compound filters).
137+
The ``user`` argument is required: not all concrete managers accept
138+
a missing user (e.g. :class:`PermissionManager.visible_to_user` has
139+
no default), so the protocol pins the strictest contract. Callers
140+
should pass an :class:`~django.contrib.auth.models.AnonymousUser`
141+
when no authenticated principal is available. ``Any`` is used
142+
because the concrete user type varies across managers
143+
(``AbstractUser``, ``AnonymousUser``, or a ``Q`` for compound
144+
filters).
151145
"""
152146

153-
def visible_to_user(self, user: Any = None) -> QuerySet[Any]:
147+
def visible_to_user(self, user: Any) -> QuerySet[Any]:
154148
"""Return a queryset filtered to objects visible to ``user``."""
155149
...
156150

@@ -163,6 +157,18 @@ def visible_to_user(self, user: Any = None) -> QuerySet[Any]:
163157
class StreamObserverProtocol(Protocol):
164158
"""Callable invoked by framework adapters as streaming events emit.
165159
160+
.. important::
161+
Must be kept in sync with
162+
:class:`opencontractserver.llms.types.StreamObserver`. The two
163+
definitions are duplicated (rather than re-exported) on purpose:
164+
``protocols.py`` is imported by
165+
``opencontractserver.shared.Managers`` during Django app loading,
166+
and ``opencontractserver.llms.types`` lives in a package whose
167+
``__init__`` eagerly pulls in heavy LLM machinery
168+
(``api`` → conversation models, agent factories). Re-exporting
169+
would force every importer of this lightweight types module to
170+
execute the LLM stack at startup and risks circular imports.
171+
166172
Mirrors :class:`opencontractserver.llms.types.StreamObserver` so it
167173
can be referenced from non-LLM code (notifications, websockets)
168174
without importing ``llms.types``.

0 commit comments

Comments
 (0)