"""Read-only Jira knowledge provider for search_project_knowledge. Retrieval reuses the shared lexical scoring helpers extracted from the workspace-file provider so ranking is identical across sources. The index is a local JSON store populated by ``JiraSyncService`` — this provider never talks to Jira directly at query time, which keeps search latency bounded and independent of upstream availability. Project isolation is structural: the target resolver derives the Jira project from the *identity*, never from the caller's ``project_id`` argument. Even if policy were misconfigured, the provider refuses to serve results from a project that does not match the resolved target. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Protocol from ..foundation import IdentityContext, ProviderError, decode_offset_cursor from ._shared_scoring import chunk, score_chunk, terms _PAGE_SIZE_BY_DETAIL = {"summary": 3, "standard": 5, "full": 10} _EXCERPT_CHARS_BY_DETAIL = {"summary": 200, "standard": 600, "full": 1200} class JiraKnowledgeProviderProtocol(Protocol): """Contract satisfied by the real provider and test doubles.""" def search_knowledge(self, **arguments: Any) -> dict[str, Any]: ... class UnconfiguredJiraKnowledgeProvider: """Returned when Jira KB is not enabled for this identity/project. Always raises ``UNAVAILABLE`` rather than returning empty results — empty would be indistinguishable from "searched and found nothing". """ def search_knowledge(self, **arguments: Any) -> dict[str, Any]: raise ProviderError( "UNAVAILABLE", "Jira Project Knowledge is not configured for this environment.", retryable=False, ) @dataclass(frozen=True) class _JiraKbTarget: """Resolved index scope for one identity.""" cowork_project_id: str jira_project_key: str class JiraKbTargetResolver(Protocol): def resolve(self, identity: IdentityContext) -> _JiraKbTarget: ... class JiraKbAccessResolver(Protocol): def resolve(self, identity: IdentityContext, target: _JiraKbTarget) -> None: ... @dataclass(frozen=True) class _DefaultAccessResolver: """No-op access check — isolation is enforced structurally by the target.""" def resolve(self, identity: IdentityContext, target: _JiraKbTarget) -> None: pass class JiraKnowledgeProvider: """Search the synced Jira knowledge index for one project. Lexical scoring, chunking, pagination and bounding reuse the shared helpers so behaviour matches the workspace-file provider exactly. """ def __init__( self, target: _JiraKbTarget, *, index: Any | None = None, ) -> None: self._target = target if index is not None: self._index = index else: from ....application.jira_knowledge.index_repository import JiraKnowledgeIndex self._index = JiraKnowledgeIndex() def search_knowledge( self, *, project_id: str, query: str, detail: str = "standard", top_k: int = 5, language: str | None = None, cursor: str | None = None, **_: Any, ) -> dict[str, Any]: # Defense in depth: refuse if caller's project_id disagrees with the # identity-resolved target, even when policy allowed it through. if project_id != self._target.cowork_project_id: raise ProviderError( "INTERNAL", "Project scope mismatch between identity and request.", retryable=False, ) offset = decode_offset_cursor(cursor) page_size = min(top_k, _PAGE_SIZE_BY_DETAIL.get(detail, 5)) excerpt_chars = _EXCERPT_CHARS_BY_DETAIL.get(detail, 600) issues = self._index.list_all(self._target.cowork_project_id) query_terms = terms(query) scored: list[tuple[float, str, str, str, dict]] = [] for issue in issues: text = issue.chunk_text() chunks_with_headings = chunk(text) for heading, body in chunks_with_headings: s = score_chunk(body, heading, issue.knowledge_id, query_terms) if s > 0: scored.append((s, heading, body, issue.knowledge_id, issue)) scored.sort(key=lambda t: t[0], reverse=True) total_matches = len(scored) page = scored[offset : offset + page_size] remaining = max(0, total_matches - offset - len(page)) truncated = remaining > 0 next_cursor = str(offset + len(page)) if truncated else None now = datetime.now(timezone.utc).isoformat() items = [] for s, heading, body, kid, issue in page: excerpt = body[:excerpt_chars].strip() items.append({ "document_id": kid, "chunk_id": f"{kid}#{offset}", "title": heading or issue.title, "excerpt": excerpt, "score": s, "source": { "system": "jira", "url": issue.provenance.source_url, "revision": issue.provenance.source_updated or issue.ingested_at, "retrieved_at": now, }, }) return { "project_id": project_id, "query": query, "items": tuple(items), "truncated": truncated, "returned": len(items), "remaining": remaining, "next_cursor": next_cursor, } def build_provider( identity: IdentityContext, *, target_resolver: JiraKbTargetResolver | None = None, access_resolver: JiraKbAccessResolver | None = None, ) -> JiraKnowledgeProviderProtocol: """Build the Jira knowledge provider for one identity. Returns ``UnconfiguredJiraKnowledgeProvider`` when no binding exists so the runtime can fall back to the workspace-file provider transparently. """ from ....application.jira_knowledge.target_resolver import JiraTargetResolver as _RealResolver resolver = target_resolver or _RealResolver() try: target = resolver.resolve(identity) except ProviderError: return UnconfiguredJiraKnowledgeProvider() kb_target = _JiraKbTarget( cowork_project_id=target.cowork_project_id, jira_project_key=target.jira_project_key, ) access = access_resolver or _DefaultAccessResolver() access.resolve(identity, kb_target) return JiraKnowledgeProvider(kb_target) __all__ = [ "JiraKnowledgeProvider", "UnconfiguredJiraKnowledgeProvider", "build_provider", ]