update project knowledge function

This commit is contained in:
thanhnv
2026-09-07 00:10:08 +09:00
parent e5fa21ecfd
commit 4cdd4fc98e
21 changed files with 3775 additions and 1 deletions
@@ -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",
]