diff --git a/README-en.md b/README-en.md index 8a5caa0..f679cb1 100644 --- a/README-en.md +++ b/README-en.md @@ -52,10 +52,13 @@ must not fabricate cache invalidation. Core vector, page, and embedding caches a also rebuildable performance layers, never authorities for revisions, events, facts, actor knowledge, or snapshots. -Authority history reads are bounded at the service boundary. `EventService.list*` -and `RevisionService.history` accept a validated `limit` plus `offset`, so domain +Authority history reads are bounded at the service boundary. `EventService.list*`, +`MemoryService.search`, `ActorKnowledgeService.search`, `ContinuityService.context`, +and `RevisionService.history` accept a bounded page plus validated `offset`, so domain MCPs can implement opaque continuation cursors beyond the first 100 records without loading the complete campaign history into a public tool response. +Continuity advances fact, event, and actor-knowledge candidate windows together and +reports per-stream lookahead plus `next_offset` under `retrieval.pagination`. ## Current domain implementations @@ -185,6 +188,7 @@ The package supplies its profile, character schema, module parser, and rules eng - Snapshots, branches, and revisions are authoritative; vector hits are not. - A snapshot is a self-contained full checkpoint; only its `recap` is a delta from the parent. Integrity covers the payload, DAG ancestry, and fact/event/actor-knowledge bindings. - Objective facts use stable `fact_key` identities with branch-scoped revision heads and optimistic revision checks. Subjective actor knowledge remains a separate ledger. +- Fact recency and `updated_at` are derived from the selected branch head. Revising a sibling timeline cannot reorder or retimestamp the current branch. Direct actor-knowledge revisions preserve omitted epistemic, confidence, provenance, cause, and disclosure fields; pass an explicit value (including `source_event_id=None`) to change one. A `ContinuityCommitService` revision binds the newly committed scene event as its provenance while preserving the other omitted fields. - Prefer `ContinuityCommitService` at scene boundaries so the event, fact upserts, actor-knowledge changes, and optional snapshot commit as one transaction. - Checkout never silently discards a dirty worktree; save a snapshot before switching branches. - Writes should use expected revisions and idempotency keys so agent retries cannot duplicate effects. diff --git a/README.md b/README.md index ef95c5f..9acf106 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,12 @@ no-op 写入不得伪造 cache 失效。Core 的向量、页面与 embedding cac 以上三个垂直仓库是当前唯一源码入口。原独立 MCP、Skills、UI 与通用 Module Generator 仓库已归档,只保留只读历史;新集成不得依赖其分支、发布或文档。 -权威历史读取在服务边界保持有界。`EventService.list*` 与 -`RevisionService.history` 接受经过校验的 `limit` 和 `offset`,让领域 MCP +权威历史读取在服务边界保持有界。`EventService.list*`、`MemoryService.search`、 +`ActorKnowledgeService.search`、`ContinuityService.context` 与 `RevisionService.history` +接受有界分页和经过校验的 `offset`,让领域 MCP 可以用 opaque continuation cursor 翻过前 100 条记录,而不必把完整战役历史加载到公开工具响应。 +Continuity 会同步推进事实、事件和角色认知的候选窗口,并在 +`retrieval.pagination` 中返回逐流前瞻状态与 `next_offset`。 ## MCP 2026 身份基线 @@ -180,6 +183,7 @@ my_system = "my_package.system:get_system" - Snapshot、branch 和 revision 是权威连续性;向量命中不是。 - 客观事实使用稳定 `fact_key`,在分支内通过 revision head 演进;修订应携带 `expected_revision_id`。角色的主观知识继续使用独立的 ActorKnowledge ledger。 +- 事实的 recency 与 `updated_at` 来自所选分支的 revision head;修改旁支不会重排或重标当前分支。直接调用 ActorKnowledge 修订会保留未提供的认知状态、置信度、来源、成因与披露范围;只有显式传值(包括 `source_event_id=None`)才修改对应字段。通过 `ContinuityCommitService` 修订时,新提交的场景事件会成为来源,其余未提供字段仍保留。 - 场景收尾优先使用 `ContinuityCommitService`,在同一事务中写入事件、事实、 角色认知和可选 Snapshot,避免产生半保存状态。 - Snapshot 在语义上是可独立恢复的全量 checkpoint;`recap` 才是相对父节点的差量摘要。完整性校验同时覆盖 payload、DAG 祖先链以及 fact/event/actor-knowledge bindings。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cc9b25..7938ac5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -93,11 +93,26 @@ database and runtime because v30-v32 deliberately have no downgrade protocol. supply a campaign-stable `fact_key`, normally composed from a subject reference and predicate. `MemoryRevision` stores lifecycle, disclosure, importance, valid-time, and source-event evidence; `BranchFactHead` selects the revision -visible in one timeline. +visible in one timeline. Public recency and `updated_at` come from that selected +revision, not from the campaign-wide fact identity, so a sibling branch cannot +reorder or retimestamp the current timeline. `ActorKnowledge` stores what one live actor believes or remembers. It must not be replaced by campaign facts or free-form character notes. Forgotten and superseded heads are excluded from normal recall but remain available for audit. +Revising only the proposition preserves omitted epistemic status, confidence, +source event, cause, and disclosure fields; callers explicitly pass a field to +change it and may explicitly pass `source_event_id=None` to clear provenance. +The scene-boundary `ContinuityCommitService` instead binds its newly committed +event as provenance while preserving the other omitted epistemic fields. + +`ContinuityService.context` pages the branch-visible fact, event, and optional +actor-knowledge candidate streams with one validated offset. Visibility and +context-anchor exclusion happen before each bounded window, while shared-budget +selection happens inside it. The response exposes per-stream lookahead and the +next offset under `retrieval.pagination`, allowing domain MCPs to wrap the state +in opaque cursors without duplicating branch, disclosure, scene-projection, or +pinned-module-evidence policy. At a scene boundary, `ContinuityCommitService` is the preferred write path. It allocates the event sequence atomically and either commits every requested diff --git a/src/sagasmith_core/continuity.py b/src/sagasmith_core/continuity.py index 456fd6a..de38a54 100644 --- a/src/sagasmith_core/continuity.py +++ b/src/sagasmith_core/continuity.py @@ -64,11 +64,20 @@ def context( scope_id: str = "party", audience: str = "dm", limit: int = 8, + offset: int = 0, budget_chars: int = 12_000, related_refs: list[str] | None = None, ) -> dict[str, Any]: if audience not in CONTINUITY_AUDIENCES: raise ValueError("audience must be 'dm' or 'player'") + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100: + raise ValueError("limit must be an integer between 1 and 100") + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or not 0 <= offset <= 100_000 + ): + raise ValueError("offset must be an integer between 0 and 100000") branch = ( self.branches.current(campaign_id) if branch_id is None @@ -79,41 +88,48 @@ def context( kind=CONTEXT_ANCHOR_KIND, branch_id=branch.id, ) - facts = [ - item - for item in self.facts.search( - campaign_id, - query or " ", - limit=limit + len(anchors), - branch_id=branch.id, - ) - if item.kind != CONTEXT_ANCHOR_KIND - ][:limit] - events = self.events.list_for_audience( + fact_page = self.facts.search( + campaign_id, + query or " ", + limit=limit + 1, + offset=offset, + branch_id=branch.id, + excluded_kinds={CONTEXT_ANCHOR_KIND}, + disclosure_scopes=( + PLAYER_MEMORY_DISCLOSURE_SCOPES if audience == "player" else None + ), + ) + event_page = self.events.list_for_audience( campaign_id, audience=audience, actor_id=actor_id, - limit=limit, + limit=limit + 1, + offset=offset, branch_id=branch.id, ) - knowledge = [] + knowledge_page = [] if actor_id: - knowledge = self.knowledge.search( + knowledge_page = self.knowledge.search( campaign_id, actor_id=actor_id, query=query or " ", branch_id=branch.id, - limit=limit, + limit=limit + 1, + offset=offset, + disclosure_scopes=( + PLAYER_OWNED_ACTOR_DISCLOSURE_SCOPES + if audience == "player" + else None + ), ) - if audience == "player": - facts = [ - item for item in facts if item.disclosure_scope in PLAYER_MEMORY_DISCLOSURE_SCOPES - ] - knowledge = [ - item - for item in knowledge - if item.disclosure_scope in PLAYER_OWNED_ACTOR_DISCLOSURE_SCOPES - ] + facts = fact_page[:limit] + events = event_page[-limit:] + knowledge = knowledge_page[:limit] + stream_has_more = { + "facts": len(fact_page) > limit, + "events": len(event_page) > limit, + "actor_knowledge": len(knowledge_page) > limit, + } current = self.branches.current(campaign_id) if branch.id == current.id: scoped_state = self.modules.current_scene(campaign_id, scope_id=scope_id) @@ -160,6 +176,27 @@ def context( retrieval["strategy"] = "lexical_structured_pinned_module_evidence_v3" retrieval["active_context_refs"] = sorted(active_refs) retrieval["pinned_module_evidence_count"] = len(module_evidence) + has_more = any(stream_has_more.values()) + retrieval["pagination"] = { + "offset": offset, + "page_limit": limit, + "has_more": has_more, + "next_offset": offset + limit if has_more else None, + "streams": { + "facts": { + "candidate_count": len(facts), + "has_more": stream_has_more["facts"], + }, + "events": { + "candidate_count": len(events), + "has_more": stream_has_more["events"], + }, + "actor_knowledge": { + "candidate_count": len(knowledge), + "has_more": stream_has_more["actor_knowledge"], + }, + }, + } return { "campaign_id": campaign_id, "branch": asdict(branch), diff --git a/src/sagasmith_core/continuity_commit.py b/src/sagasmith_core/continuity_commit.py index f5a27a3..4871d1b 100644 --- a/src/sagasmith_core/continuity_commit.py +++ b/src/sagasmith_core/continuity_commit.py @@ -355,11 +355,19 @@ def _apply_knowledge( branch_id, head_snapshot_id, proposition=self._required_text(data, "proposition"), - epistemic_status=str(data.get("epistemic_status", "known")), - confidence=int(data.get("confidence", 3)), + epistemic_status=( + str(data["epistemic_status"]) + if data.get("epistemic_status") is not None + else None + ), + confidence=(int(data["confidence"]) if data.get("confidence") is not None else None), source_event_id=str(source_event_id), - cause=str(data.get("cause", "told_by")), - disclosure_scope=str(data.get("disclosure_scope", "dm")), + cause=str(data["cause"]) if data.get("cause") is not None else None, + disclosure_scope=( + str(data["disclosure_scope"]) + if data.get("disclosure_scope") is not None + else None + ), expected_revision_id=data.get("expected_revision_id"), ) diff --git a/src/sagasmith_core/knowledge.py b/src/sagasmith_core/knowledge.py index 33b5679..56a545a 100644 --- a/src/sagasmith_core/knowledge.py +++ b/src/sagasmith_core/knowledge.py @@ -29,6 +29,13 @@ INACTIVE_ACTOR_KNOWLEDGE_STATUSES = frozenset({"forgotten", "superseded"}) +class _UnsetSourceEvent: + """Distinguish an omitted source event from an explicit evidence clear.""" + + +_UNSET_SOURCE_EVENT = _UnsetSourceEvent() + + @dataclass(frozen=True) class ActorKnowledgeInfo: id: str @@ -104,18 +111,16 @@ def revise( knowledge_id: str, *, proposition: str, - epistemic_status: str = "known", - confidence: int = 3, - source_event_id: str | None = None, - cause: str = "told_by", - disclosure_scope: str = "dm", + epistemic_status: str | None = None, + confidence: int | None = None, + source_event_id: str | None | _UnsetSourceEvent = _UNSET_SOURCE_EVENT, + cause: str | None = None, + disclosure_scope: str | None = None, branch_id: str | None = None, expected_revision_id: str | None = None, idempotency_key: str | None = None, idempotency_write: IdempotencyWrite | None = None, ) -> ActorKnowledgeInfo: - self._validate_status(epistemic_status) - self._validate_disclosure_scope(disclosure_scope) with self.database.transaction() as session: knowledge = session.get(ActorKnowledge, knowledge_id) if knowledge is None: @@ -221,22 +226,13 @@ def _revise_in_session( head_snapshot_id: str | None, *, proposition: str, - epistemic_status: str, - confidence: int, - source_event_id: str | None, - cause: str, - disclosure_scope: str, + epistemic_status: str | None, + confidence: int | None, + source_event_id: str | None | _UnsetSourceEvent, + cause: str | None, + disclosure_scope: str | None, expected_revision_id: str | None, ) -> ActorKnowledgeInfo: - self._validate_status(epistemic_status) - self._validate_disclosure_scope(disclosure_scope) - self._validate_event( - session, - source_event_id, - knowledge.campaign_id, - branch_id, - head_snapshot_id, - ) head = session.get( BranchActorKnowledgeHead, {"branch_id": branch_id, "knowledge_id": knowledge.id}, @@ -248,15 +244,40 @@ def _revise_in_session( f"expected actor-knowledge revision {expected_revision_id}, " f"current revision is {head.revision_id}" ) + current = session.get(ActorKnowledgeRevision, head.revision_id) + if current is None: + raise LookupError(head.revision_id) + resolved_status = ( + current.epistemic_status if epistemic_status is None else epistemic_status + ) + resolved_confidence = current.confidence if confidence is None else confidence + resolved_source_event_id = ( + current.source_event_id + if isinstance(source_event_id, _UnsetSourceEvent) + else source_event_id + ) + resolved_cause = current.cause if cause is None else cause + resolved_disclosure_scope = ( + current.disclosure_scope if disclosure_scope is None else disclosure_scope + ) + self._validate_status(resolved_status) + self._validate_disclosure_scope(resolved_disclosure_scope) + self._validate_event( + session, + resolved_source_event_id, + knowledge.campaign_id, + branch_id, + head_snapshot_id, + ) revision = self._revision( knowledge.id, parent_id=head.revision_id, proposition=proposition, - epistemic_status=epistemic_status, - confidence=confidence, - source_event_id=source_event_id, - cause=cause, - disclosure_scope=disclosure_scope, + epistemic_status=resolved_status, + confidence=resolved_confidence, + source_event_id=resolved_source_event_id, + cause=resolved_cause, + disclosure_scope=resolved_disclosure_scope, ) session.add(revision) session.flush() @@ -364,9 +385,18 @@ def search( query: str, branch_id: str | None = None, limit: int = 8, + offset: int = 0, include_inactive: bool = False, disclosure_scopes: set[str] | frozenset[str] | None = None, ) -> list[ActorKnowledgeInfo]: + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500: + raise ValueError("limit must be an integer between 1 and 500") + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or not 0 <= offset <= 100_000 + ): + raise ValueError("offset must be an integer between 0 and 100000") values = self.list( campaign_id, actor_id=actor_id, @@ -380,7 +410,7 @@ def search( -lexical_score(query, title=value.knowledge_key, content=value.proposition) ), ) - return ranked[: max(1, min(limit, 100))] + return ranked[offset : offset + limit] @staticmethod def _revision( diff --git a/src/sagasmith_core/memory.py b/src/sagasmith_core/memory.py index 47922ab..ec5cf36 100644 --- a/src/sagasmith_core/memory.py +++ b/src/sagasmith_core/memory.py @@ -386,7 +386,7 @@ def list( .join(BranchFactHead, BranchFactHead.memory_id == CampaignMemory.id) .join(MemoryRevision, MemoryRevision.id == BranchFactHead.revision_id) .where(BranchFactHead.branch_id == branch.id) - .order_by(CampaignMemory.updated_at.desc(), CampaignMemory.id) + .order_by(MemoryRevision.created_at.desc(), CampaignMemory.id) ) if kind: statement = statement.where(CampaignMemory.kind == kind) @@ -400,10 +400,35 @@ def search( query: str, *, limit: int = 8, + offset: int = 0, branch_id: str | None = None, include_inactive: bool = False, + excluded_kinds: set[str] | frozenset[str] | None = None, + disclosure_scopes: set[str] | frozenset[str] | None = None, ) -> list[MemoryInfo]: + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500: + raise ValueError("limit must be an integer between 1 and 500") + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or not 0 <= offset <= 100_000 + ): + raise ValueError("offset must be an integer between 0 and 100000") + selected_scopes = None if disclosure_scopes is None else set(disclosure_scopes) + if selected_scopes is not None: + unknown_scopes = selected_scopes - MEMORY_DISCLOSURE_SCOPES + if unknown_scopes: + raise ValueError( + f"invalid campaign-memory disclosure scopes: {sorted(unknown_scopes)}" + ) + selected_excluded_kinds = set(excluded_kinds or ()) values = self.list(campaign_id, branch_id=branch_id, include_inactive=include_inactive) + values = [ + item + for item in values + if item.kind not in selected_excluded_kinds + and (selected_scopes is None or item.disclosure_scope in selected_scopes) + ] ranked = sorted( values, key=lambda item: ( @@ -425,7 +450,7 @@ def search( item.fact_key, ), ) - return ranked[: max(1, min(limit, 100))] + return ranked[offset : offset + limit] def list_for_subject_refs( self, @@ -720,5 +745,5 @@ def _info(memory: CampaignMemory, revision: MemoryRevision) -> MemoryInfo: importance=revision.importance, disclosure_scope=revision.disclosure_scope, created_at=memory.created_at.isoformat(), - updated_at=memory.updated_at.isoformat(), + updated_at=revision.created_at.isoformat(), ) diff --git a/tests/test_state_documents.py b/tests/test_state_documents.py index 3c894d7..92fbff2 100644 --- a/tests/test_state_documents.py +++ b/tests/test_state_documents.py @@ -1948,6 +1948,36 @@ def test_campaign_memory_upsert_has_stable_identity_and_optimistic_revision(data ) +def test_campaign_memory_recency_and_updated_at_are_branch_local(database) -> None: + campaign = CampaignService(database).create(system_id="neutral", name="Branch recency") + memories = MemoryService(database) + snapshots = SnapshotService(database) + branches = BranchService(database) + first = memories.add(campaign.id, fact_key="first", content="First on main") + base = snapshots.create(campaign.id, label="Before the branch diverges") + main = branches.current(campaign.id) + alternate = branches.create( + campaign.id, + name="alternate-recency", + from_snapshot_id=base.id, + ) + second = memories.add(campaign.id, fact_key="second", content="Second on main") + + before = {item.fact_key: item for item in memories.list(campaign.id, branch_id=main.id)} + revised = memories.revise( + first.id, + content="First changed only on alternate", + branch_id=alternate.id, + expected_revision_id=first.revision_id, + ) + after = memories.list(campaign.id, branch_id=main.id) + + assert [item.fact_key for item in after] == [second.fact_key, first.fact_key] + assert after[1].content == "First on main" + assert after[1].updated_at == before[first.fact_key].updated_at + assert revised.updated_at != after[1].updated_at + + def test_campaign_memory_upsert_reuses_identity_on_a_sibling_branch(database) -> None: campaign = CampaignService(database).create(system_id="dnd5e", name="Branch facts") memories = MemoryService(database) @@ -2494,6 +2524,81 @@ def test_actor_knowledge_list_and_search_filter_disclosure_scopes_per_branch( ) +def test_actor_knowledge_revise_preserves_omitted_epistemic_fields(database) -> None: + campaign = CampaignService(database).create(system_id="neutral", name="Knowledge edits") + actor = CharacterService(database).create( + system_id="neutral", + campaign_id=campaign.id, + name="Witness", + character_type="npc", + ) + event = EventService(database).add( + campaign.id, + summary="The witness studies the sigil.", + audience_scope="actor", + participants=[{"actor_id": actor.id, "role": "witness"}], + ) + knowledge = ActorKnowledgeService(database) + original = knowledge.add( + campaign.id, + actor_id=actor.id, + knowledge_key="sigil-color", + proposition="The sigil is blue.", + epistemic_status="belief", + confidence=5, + source_event_id=event.id, + cause="inferred", + disclosure_scope="owner", + ) + + revised = knowledge.revise( + original.id, + proposition="The sigil is azure.", + expected_revision_id=original.revision_id, + ) + assert ( + revised.epistemic_status, + revised.confidence, + revised.source_event_id, + revised.cause, + revised.disclosure_scope, + ) == ("belief", 5, event.id, "inferred", "owner") + + committed = ContinuityCommitService(database).commit( + campaign.id, + event={ + "summary": "The witness reconsiders the shade.", + "audience_scope": "actor", + "participants": [{"actor_id": actor.id, "role": "witness"}], + }, + actor_knowledge=[ + { + "action": "revise", + "knowledge_id": original.id, + "proposition": "The sigil is blue-green.", + "expected_revision_id": revised.revision_id, + } + ], + ) + settled = committed["actor_knowledge"][0] + assert ( + settled["epistemic_status"], + settled["confidence"], + settled["cause"], + settled["disclosure_scope"], + ) == ("belief", 5, "inferred", "owner") + assert settled["source_event_id"] == committed["event"]["id"] + + cleared = knowledge.revise( + original.id, + proposition="The sigil's source is no longer remembered.", + source_event_id=None, + expected_revision_id=settled["revision_id"], + ) + assert cleared.source_event_id is None + assert cleared.disclosure_scope == "owner" + + def test_event_and_all_witness_knowledge_commit_or_rollback_together(database) -> None: campaign = CampaignService(database).create(system_id="dnd5e", name="Atomic witnesses") characters = CharacterService(database) @@ -2557,6 +2662,227 @@ def test_event_history_pages_beyond_first_hundred_without_full_history_load(data events.list(campaign.id, offset=-1) +def test_fact_and_actor_knowledge_search_page_beyond_first_hundred(database) -> None: + campaign = CampaignService(database).create(system_id="neutral", name="Long memory search") + actor = CharacterService(database).create( + system_id="neutral", + campaign_id=campaign.id, + name="Archivist", + character_type="npc", + ) + memories = MemoryService(database) + knowledge = ActorKnowledgeService(database) + for index in range(125): + memories.add( + campaign.id, + fact_key=f"archive:{index:03d}", + content="Shared archive marker.", + ) + knowledge.add( + campaign.id, + actor_id=actor.id, + knowledge_key=f"archive:{index:03d}", + proposition="Shared archive marker.", + ) + + assert [item.fact_key for item in memories.search( + campaign.id, + "archive marker", + limit=101, + )] == [f"archive:{index:03d}" for index in range(101)] + assert [item.knowledge_key for item in knowledge.search( + campaign.id, + actor_id=actor.id, + query="archive marker", + limit=101, + )] == [f"archive:{index:03d}" for index in range(101)] + assert [item.fact_key for item in memories.search( + campaign.id, + "archive marker", + limit=10, + offset=100, + )] == [f"archive:{index:03d}" for index in range(100, 110)] + assert [item.knowledge_key for item in knowledge.search( + campaign.id, + actor_id=actor.id, + query="archive marker", + limit=10, + offset=100, + )] == [f"archive:{index:03d}" for index in range(100, 110)] + assert memories.search(campaign.id, "archive marker", offset=125) == [] + assert knowledge.search( + campaign.id, + actor_id=actor.id, + query="archive marker", + offset=125, + ) == [] + with pytest.raises(ValueError, match="offset"): + memories.search(campaign.id, "archive marker", offset=-1) + with pytest.raises(ValueError, match="offset"): + knowledge.search(campaign.id, actor_id=actor.id, query="archive marker", offset=True) + with pytest.raises(ValueError, match="limit"): + memories.search(campaign.id, "archive marker", limit=501) + with pytest.raises(ValueError, match="limit"): + knowledge.search( + campaign.id, + actor_id=actor.id, + query="archive marker", + limit=0, + ) + + +def test_continuity_context_pages_all_three_authority_streams(database) -> None: + campaign = CampaignService(database).create(system_id="neutral", name="Paged continuity") + actor = CharacterService(database).create( + system_id="neutral", + campaign_id=campaign.id, + name="Long-lived witness", + character_type="npc", + ) + memories = MemoryService(database) + knowledge = ActorKnowledgeService(database) + events = EventService(database) + for index in range(201): + if index < 105: + memories.add( + campaign.id, + fact_key=f"continuity:{index:03d}", + content="Shared continuity marker.", + ) + if index < 102: + knowledge.add( + campaign.id, + actor_id=actor.id, + knowledge_key=f"continuity:{index:03d}", + proposition="Shared continuity marker.", + ) + events.add(campaign.id, summary=f"Shared continuity marker {index:03d}.") + + continuity = ContinuityService(database) + first = continuity.context( + campaign.id, + actor_id=actor.id, + query="continuity marker", + limit=100, + budget_chars=100_000, + ) + second = continuity.context( + campaign.id, + actor_id=actor.id, + query="continuity marker", + limit=100, + offset=100, + budget_chars=100_000, + ) + third = continuity.context( + campaign.id, + actor_id=actor.id, + query="continuity marker", + limit=100, + offset=200, + budget_chars=100_000, + ) + + assert first["retrieval"]["pagination"] == { + "offset": 0, + "page_limit": 100, + "has_more": True, + "next_offset": 100, + "streams": { + "facts": {"candidate_count": 100, "has_more": True}, + "events": {"candidate_count": 100, "has_more": True}, + "actor_knowledge": {"candidate_count": 100, "has_more": True}, + }, + } + assert second["retrieval"]["pagination"] == { + "offset": 100, + "page_limit": 100, + "has_more": True, + "next_offset": 200, + "streams": { + "facts": {"candidate_count": 5, "has_more": False}, + "events": {"candidate_count": 100, "has_more": True}, + "actor_knowledge": {"candidate_count": 2, "has_more": False}, + }, + } + assert third["retrieval"]["pagination"] == { + "offset": 200, + "page_limit": 100, + "has_more": False, + "next_offset": None, + "streams": { + "facts": {"candidate_count": 0, "has_more": False}, + "events": {"candidate_count": 1, "has_more": False}, + "actor_knowledge": {"candidate_count": 0, "has_more": False}, + }, + } + assert [item["fact_key"] for item in second["facts"]] == [ + f"continuity:{index:03d}" for index in range(100, 105) + ] + assert [item["knowledge_key"] for item in second["actor_knowledge"]] == [ + f"continuity:{index:03d}" for index in range(100, 102) + ] + assert [item["sequence"] for item in second["events"]] == list(range(2, 102)) + assert [item["sequence"] for item in third["events"]] == [1] + with pytest.raises(ValueError, match="limit"): + continuity.context(campaign.id, limit=101) + with pytest.raises(ValueError, match="offset"): + continuity.context(campaign.id, offset=True) + + +def test_player_continuity_filters_private_memory_before_paging(database) -> None: + campaign = CampaignService(database).create(system_id="neutral", name="Player continuity") + actor = CharacterService(database).create( + system_id="neutral", + campaign_id=campaign.id, + name="Player witness", + character_type="pc", + ) + memories = MemoryService(database) + knowledge = ActorKnowledgeService(database) + for index in range(3): + memories.add( + campaign.id, + fact_key=f"private:{index}", + content="Shared player marker.", + disclosure_scope="dm", + ) + knowledge.add( + campaign.id, + actor_id=actor.id, + knowledge_key=f"private:{index}", + proposition="Shared player marker.", + disclosure_scope="dm", + ) + memories.add( + campaign.id, + fact_key="visible:fact", + content="Shared player marker.", + disclosure_scope="party", + ) + knowledge.add( + campaign.id, + actor_id=actor.id, + knowledge_key="visible:knowledge", + proposition="Shared player marker.", + disclosure_scope="owner", + ) + + context = ContinuityService(database).context( + campaign.id, + actor_id=actor.id, + audience="player", + query="player marker", + limit=1, + ) + + assert [item["fact_key"] for item in context["facts"]] == ["visible:fact"] + assert [item["knowledge_key"] for item in context["actor_knowledge"]] == [ + "visible:knowledge" + ] + assert context["retrieval"]["pagination"]["has_more"] is False + + def test_actor_scoped_events_follow_visible_actor_knowledge(database) -> None: campaign = CampaignService(database).create(system_id="dnd5e", name="Separate witnesses") characters = CharacterService(database)