196 lines
6.6 KiB
Python
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",
|
|
] |