update project knowledge function
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Shared lexical scoring, chunking and normalization helpers.
|
||||
|
||||
Extracted from ``knowledge.py`` so both the workspace-file provider and the
|
||||
Jira-knowledge provider use identical ranking without duplicating logic.
|
||||
The scoring is a bounded term-overlap floor — not embeddings — and is honest
|
||||
about what it is. Upgrade path: swap ``score_chunk`` for a Cowork-provided
|
||||
semantic ranker when recall (not plumbing) becomes the bottleneck.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import List, Tuple
|
||||
|
||||
_WORD_PATTERN = re.compile(r"\w+", re.UNICODE)
|
||||
_HEADING_PATTERN = re.compile(r"^(#{1,6})[ \t]+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
# Tunables shared across providers. Individual providers may cap these further
|
||||
# but must never exceed them.
|
||||
MAX_QUERY_TERMS = 32
|
||||
CHUNK_CHARS = 1_200
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""Unicode-normalize + casefold so term matching is language-neutral."""
|
||||
return unicodedata.normalize("NFKC", text).casefold()
|
||||
|
||||
|
||||
def terms(text: str) -> List[str]:
|
||||
"""Tokenize into at most ``MAX_QUERY_TERMS`` lowercase words."""
|
||||
return _WORD_PATTERN.findall(normalize(text))[:MAX_QUERY_TERMS]
|
||||
|
||||
|
||||
def chunk(text: str) -> List[Tuple[str, str]]:
|
||||
"""Split ``text`` into ``(heading, body)`` chunks.
|
||||
|
||||
Markdown headings give a citable section title; unheaded text falls back to
|
||||
fixed-size windows so every chunk stays bounded.
|
||||
"""
|
||||
headings = list(_HEADING_PATTERN.finditer(text))
|
||||
if not headings:
|
||||
return [("", text[i : i + CHUNK_CHARS]) for i in range(0, len(text), CHUNK_CHARS)]
|
||||
chunks: List[Tuple[str, str]] = []
|
||||
preamble = text[: headings[0].start()].strip()
|
||||
if preamble:
|
||||
chunks.append(("", preamble[:CHUNK_CHARS]))
|
||||
for index, match in enumerate(headings):
|
||||
end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
|
||||
body = text[match.end() : end]
|
||||
heading = match.group(2).strip().rstrip("#").strip()
|
||||
for start in range(0, max(len(body), 1), CHUNK_CHARS):
|
||||
chunks.append((heading, body[start : start + CHUNK_CHARS]))
|
||||
return chunks
|
||||
|
||||
|
||||
def score_chunk(chunk_text: str, heading: str, document_id: str, query_terms: List[str]) -> float:
|
||||
"""Term-coverage score in ``[0, 1]``, weighted toward heading/title matches.
|
||||
|
||||
Returns ``0.0`` when no query term appears anywhere in the chunk. The score
|
||||
is coverage, never a fabricated similarity.
|
||||
"""
|
||||
if not query_terms:
|
||||
return 0.0
|
||||
body = normalize(chunk_text)
|
||||
label = normalize(f"{heading} {document_id}")
|
||||
matched = 0
|
||||
weighted = 0.0
|
||||
for term in query_terms:
|
||||
in_body = term in body
|
||||
in_label = term in label
|
||||
if not (in_body or in_label):
|
||||
continue
|
||||
matched += 1
|
||||
weighted += 1.0 if in_label else 0.6
|
||||
if not matched:
|
||||
return 0.0
|
||||
coverage = matched / len(query_terms)
|
||||
emphasis = weighted / len(query_terms)
|
||||
return round(min(1.0, 0.7 * coverage + 0.3 * emphasis), 4)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"normalize",
|
||||
"terms",
|
||||
"chunk",
|
||||
"score_chunk",
|
||||
"CHUNK_CHARS",
|
||||
"MAX_QUERY_TERMS",
|
||||
"_HEADING_PATTERN",
|
||||
"_WORD_PATTERN",
|
||||
]
|
||||
@@ -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