update project knowledge function
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""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",
|
||||
]
|
||||
Reference in New Issue
Block a user