Files
cowork-local/mcp_servers/project_context/providers/jira_knowledge.py
T

196 lines
6.6 KiB
Python

"""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",
]