feat(mcp): add project issue context and knowledge search
This commit was merged in pull request #6.
This commit is contained in:
@@ -85,6 +85,23 @@ class ProviderError(RuntimeError):
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
def decode_offset_cursor(cursor: str | None) -> int:
|
||||
"""Shared opaque-cursor decoding for every paginated provider.
|
||||
|
||||
Rejected before any backend call so an invalid cursor never costs an
|
||||
upstream request.
|
||||
"""
|
||||
if cursor is None:
|
||||
return 0
|
||||
try:
|
||||
offset = int(cursor)
|
||||
except ValueError as exc:
|
||||
raise ProviderError("INVALID_INPUT", "cursor is not valid.", retryable=False) from exc
|
||||
if offset < 0:
|
||||
raise ProviderError("INVALID_INPUT", "cursor is not valid.", retryable=False)
|
||||
return offset
|
||||
|
||||
|
||||
ToolHandler = Callable[[ContractModel, Any], dict[str, Any]]
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,68 @@
|
||||
"""Provider boundary owned with get_project_issue_context."""
|
||||
"""Read-only Gitea adapter for ``get_project_issue_context``.
|
||||
|
||||
Policy runs before ``build_provider``. Target and credential resolution stay
|
||||
separate so the pilot service account can later be replaced by on-behalf-of
|
||||
credentials without changing the tool or provider contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..foundation import IdentityContext, ProviderError
|
||||
import requests
|
||||
|
||||
from ..foundation import IdentityContext, ProviderError, decode_offset_cursor
|
||||
|
||||
# ---- tunables (documented, not hardcoded secrets) -------------------------
|
||||
_REQUEST_TIMEOUT_SECONDS = 10
|
||||
_STANDARD_RELATED_PAGE_SIZE = 20
|
||||
_FULL_RELATED_PAGE_SIZE = 100
|
||||
_SUMMARY_DESCRIPTION_CHARS = 280
|
||||
_MAX_DESCRIPTION_CHARS = 20_000
|
||||
_MAX_SCAN_CHARS = 200_000 # hard cap on regex work, independent of the display cap above
|
||||
_TRUNCATION_NOTICE = "\n\n[description truncated: exceeds the display size limit]"
|
||||
|
||||
_ISSUE_KEY_PATTERN = re.compile(r"^[1-9][0-9]*$")
|
||||
_CHECKLIST_PATTERN = re.compile(r"^[-*]\s+\[[ xX]\]\s+(.+)$", re.MULTILINE)
|
||||
_MENTION_PATTERN = re.compile(r"(?<!\w)#([1-9][0-9]*)\b")
|
||||
_URL_PATTERN = re.compile(r"https?://\S+")
|
||||
# A whole Markdown link span, label + target together — stripped as ONE unit
|
||||
# so a `#<number>` that is only the link's label text (often a cross-repo or
|
||||
# pull-request reference) is never re-guessed as a same-repo issue mention.
|
||||
_MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]]*\]\([^)]*\)")
|
||||
# ATX heading line, e.g. "# Acceptance Criteria" / "## Acceptance Criteria".
|
||||
_HEADING_PATTERN = re.compile(r"^(#{1,6})[ \t]+(.+?)\s*$", re.MULTILINE)
|
||||
_ACCEPTANCE_HEADING_NAMES = (
|
||||
"acceptance criteria",
|
||||
"tiêu chí hoàn thành",
|
||||
"tiêu chí chấp nhận",
|
||||
)
|
||||
|
||||
|
||||
def _extract_heading_section(text: str, heading_names: tuple[str, ...]) -> str | None:
|
||||
"""Return the body of the first ATX heading whose title case-insensitively
|
||||
matches one of ``heading_names``, up to the next heading of equal or
|
||||
shallower depth (or the end of ``text``). Returns ``None`` when no such
|
||||
heading exists, so the caller can fall back to the whole body."""
|
||||
wanted = {name.strip().casefold() for name in heading_names}
|
||||
headings = list(_HEADING_PATTERN.finditer(text))
|
||||
for index, match in enumerate(headings):
|
||||
heading = match.group(2).strip().rstrip("#").strip().casefold()
|
||||
if heading not in wanted:
|
||||
continue
|
||||
level = len(match.group(1))
|
||||
end = len(text)
|
||||
for later in headings[index + 1 :]:
|
||||
if len(later.group(1)) <= level:
|
||||
end = later.start()
|
||||
break
|
||||
return text[match.end() : end]
|
||||
return None
|
||||
|
||||
|
||||
class IssueProvider(Protocol):
|
||||
@@ -33,6 +91,282 @@ class UnconfiguredIssueProvider:
|
||||
)
|
||||
|
||||
|
||||
def build_provider(identity: IdentityContext) -> IssueProvider:
|
||||
"""Replace only this factory when wiring the approved read-only issue adapter."""
|
||||
return UnconfiguredIssueProvider()
|
||||
@dataclass(frozen=True)
|
||||
class _GiteaRepoTarget:
|
||||
base_url: str
|
||||
owner: str
|
||||
repo: str
|
||||
project_id: str
|
||||
|
||||
|
||||
class GiteaTargetResolver(Protocol):
|
||||
def resolve(self, identity: IdentityContext) -> _GiteaRepoTarget: ...
|
||||
|
||||
|
||||
class GiteaCredentialResolver(Protocol):
|
||||
def resolve(self, identity: IdentityContext, target: _GiteaRepoTarget) -> str: ...
|
||||
|
||||
|
||||
def _load_repo_map() -> dict[str, str]:
|
||||
raw = os.environ.get("PROJECT_CONTEXT_REPO_MAP", "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"PROJECT_CONTEXT_REPO_MAP is not valid JSON.",
|
||||
retryable=False,
|
||||
) from exc
|
||||
if not isinstance(parsed, dict) or not all(
|
||||
isinstance(k, str) and isinstance(v, str) for k, v in parsed.items()
|
||||
):
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"PROJECT_CONTEXT_REPO_MAP must map identity or project keys to 'owner/repo'.",
|
||||
retryable=False,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvironmentTargetResolver:
|
||||
def resolve(self, identity: IdentityContext) -> _GiteaRepoTarget:
|
||||
base_url = os.environ.get("GITEA_BASE_URL", "").strip().rstrip("/")
|
||||
if not base_url:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"GITEA_BASE_URL is not configured for this environment.",
|
||||
retryable=False,
|
||||
)
|
||||
repo_map = _load_repo_map()
|
||||
identity_key = f"{identity.org_unit}/{identity.customer}/{identity.project}"
|
||||
slug = repo_map.get(identity_key) or repo_map.get(identity.project, "")
|
||||
parts = slug.split("/")
|
||||
if len(parts) != 2 or not all(parts):
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"This identity is not mapped to an approved Gitea repository.",
|
||||
retryable=False,
|
||||
)
|
||||
owner, repo = parts
|
||||
return _GiteaRepoTarget(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
project_id=identity.project,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceAccountCredentialResolver:
|
||||
def resolve(self, identity: IdentityContext, target: _GiteaRepoTarget) -> str:
|
||||
del identity, target
|
||||
token = os.environ.get("GITEA_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"GITEA_TOKEN is not configured for this environment.",
|
||||
retryable=False,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def build_provider(
|
||||
identity: IdentityContext,
|
||||
*,
|
||||
target_resolver: GiteaTargetResolver | None = None,
|
||||
credential_resolver: GiteaCredentialResolver | None = None,
|
||||
) -> IssueProvider:
|
||||
"""Compose routing and credentials only after the policy has allowed the call."""
|
||||
target = (target_resolver or EnvironmentTargetResolver()).resolve(identity)
|
||||
token = (credential_resolver or ServiceAccountCredentialResolver()).resolve(identity, target)
|
||||
return GiteaIssueProvider(target, token)
|
||||
|
||||
|
||||
class GiteaIssueProvider:
|
||||
"""Read-only adapter mapping one Gitea issue/PR onto the neutral schema."""
|
||||
|
||||
def __init__(self, target: _GiteaRepoTarget, token: str) -> None:
|
||||
self._target = target
|
||||
self._token = token
|
||||
|
||||
def get_issue_context(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
issue_key: str,
|
||||
detail: str,
|
||||
cursor: str | None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if project_id != self._target.project_id:
|
||||
# Defense in depth: the runtime's policy already guarantees this
|
||||
# can never happen (DENIED would have fired first), but the
|
||||
# provider never trusts caller-supplied routing regardless.
|
||||
raise ProviderError(
|
||||
"INTERNAL",
|
||||
"Resolved provider does not match the requested project.",
|
||||
retryable=False,
|
||||
)
|
||||
if not _ISSUE_KEY_PATTERN.match(issue_key):
|
||||
raise ProviderError(
|
||||
"INVALID_INPUT",
|
||||
"issue_key must be a positive work item number.",
|
||||
retryable=False,
|
||||
)
|
||||
offset = decode_offset_cursor(cursor)
|
||||
|
||||
payload = self._fetch_issue(issue_key)
|
||||
|
||||
title = str(payload.get("title") or "")
|
||||
raw_state = str(payload.get("state") or "")
|
||||
status = raw_state if raw_state in {"open", "closed"} else "unknown"
|
||||
body = str(payload.get("body") or "")
|
||||
description = self._build_description(body, detail)
|
||||
# Bounded regardless of the actual body size: caps worst-case regex
|
||||
# cost, independently of `description`'s own display-only cap.
|
||||
scan_text = body[:_MAX_SCAN_CHARS]
|
||||
acceptance_section = _extract_heading_section(scan_text, _ACCEPTANCE_HEADING_NAMES)
|
||||
acceptance_text = acceptance_section
|
||||
if acceptance_text is None:
|
||||
acceptance_text = "" if _HEADING_PATTERN.search(scan_text) else scan_text
|
||||
acceptance_criteria = tuple(
|
||||
_CHECKLIST_PATTERN.findall(acceptance_text)
|
||||
)
|
||||
related_all = self._extract_related(scan_text, issue_key)
|
||||
|
||||
related_page, returned, remaining, truncated, next_cursor = self._paginate_related(
|
||||
related_all, detail, offset,
|
||||
)
|
||||
|
||||
html_url = str(
|
||||
payload.get("html_url")
|
||||
or f"{self._target.base_url}/{self._target.owner}/{self._target.repo}/issues/{issue_key}"
|
||||
)
|
||||
updated_at = str(payload.get("updated_at") or "")
|
||||
retrieved_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"issue_key": issue_key,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"description": description,
|
||||
"acceptance_criteria": acceptance_criteria,
|
||||
"related": related_page,
|
||||
"source": {
|
||||
"system": "gitea",
|
||||
"url": html_url,
|
||||
"revision": f"issue-updated:{updated_at or retrieved_at}",
|
||||
"retrieved_at": retrieved_at,
|
||||
},
|
||||
"truncated": truncated,
|
||||
"returned": returned,
|
||||
"remaining": remaining,
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
|
||||
# ---- internals ---------------------------------------------------
|
||||
def _build_description(self, body: str, detail: str) -> str:
|
||||
text = body.strip()
|
||||
if detail == "summary":
|
||||
return text.split("\n\n", 1)[0][:_SUMMARY_DESCRIPTION_CHARS]
|
||||
if len(text) > _MAX_DESCRIPTION_CHARS:
|
||||
return text[:_MAX_DESCRIPTION_CHARS] + _TRUNCATION_NOTICE
|
||||
return text
|
||||
|
||||
def _extract_related(self, body: str, issue_key: str) -> tuple[dict[str, str], ...]:
|
||||
# Strip whole `[label](url)` spans FIRST (as one unit) so a `#<number>`
|
||||
# that only appears as a Markdown link's label — often a cross-repo or
|
||||
# pull-request reference with its own, possibly different, URL right
|
||||
# there — is never re-guessed as "issue #<number> in this repo".
|
||||
text_without_links = _MARKDOWN_LINK_PATTERN.sub(" ", body)
|
||||
# Then strip any remaining bare URLs so a doc-anchor link like
|
||||
# ".../guide#42" is never mistaken for a cross-reference to issue #42.
|
||||
text_without_urls = _URL_PATTERN.sub(" ", text_without_links)
|
||||
numbers = sorted({int(n) for n in _MENTION_PATTERN.findall(text_without_urls) if n != issue_key})
|
||||
return tuple(
|
||||
{
|
||||
"item_id": str(number),
|
||||
"relation": "mentioned",
|
||||
"title": f"Referenced item #{number}",
|
||||
"url": f"{self._target.base_url}/{self._target.owner}/{self._target.repo}/issues/{number}",
|
||||
}
|
||||
for number in numbers
|
||||
)
|
||||
|
||||
def _paginate_related(
|
||||
self,
|
||||
related_all: tuple[dict[str, str], ...],
|
||||
detail: str,
|
||||
offset: int,
|
||||
) -> tuple[tuple[dict[str, str], ...], int, int, bool, str | None]:
|
||||
if detail == "summary":
|
||||
# Summary mode intentionally omits related items outright; it is
|
||||
# not a size-limit truncation, so callers who need them must
|
||||
# call again with detail="standard"/"full".
|
||||
remaining = len(related_all)
|
||||
return (), 0, remaining, remaining > 0, None
|
||||
|
||||
page_size = _FULL_RELATED_PAGE_SIZE if detail == "full" else _STANDARD_RELATED_PAGE_SIZE
|
||||
page = related_all[offset : offset + page_size]
|
||||
remaining = max(0, len(related_all) - (offset + page_size))
|
||||
truncated = remaining > 0
|
||||
next_cursor = str(offset + page_size) if truncated else None
|
||||
return page, len(page), remaining, truncated, next_cursor
|
||||
|
||||
def _fetch_issue(self, issue_key: str) -> dict[str, Any]:
|
||||
url = (
|
||||
f"{self._target.base_url}/api/v1/repos/{self._target.owner}/"
|
||||
f"{self._target.repo}/issues/{issue_key}"
|
||||
)
|
||||
headers = {"Authorization": f"token {self._token}"}
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS)
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise ProviderError(
|
||||
"UPSTREAM_TIMEOUT", "The Gitea request timed out.", retryable=True,
|
||||
) from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
# Never surface str(exc) — it can embed the request URL/host and,
|
||||
# in some transport errors, request headers.
|
||||
raise ProviderError(
|
||||
"UPSTREAM_ERROR", "The Gitea request failed.", retryable=True,
|
||||
) from exc
|
||||
|
||||
if response.status_code == 404:
|
||||
raise ProviderError(
|
||||
"NOT_FOUND",
|
||||
"The work item was not found or is not accessible.",
|
||||
retryable=False,
|
||||
)
|
||||
if response.status_code == 429:
|
||||
raise ProviderError("RATE_LIMITED", "Gitea rate-limited this request.", retryable=True)
|
||||
if response.status_code in (401, 403):
|
||||
raise ProviderError(
|
||||
"UPSTREAM_ERROR",
|
||||
"The read-only Gitea credential could not access the repository.",
|
||||
retryable=False,
|
||||
)
|
||||
if response.status_code >= 500:
|
||||
raise ProviderError("UPSTREAM_ERROR", "Gitea returned a server error.", retryable=True)
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(
|
||||
"UPSTREAM_ERROR", "Gitea returned an unexpected response.", retryable=False,
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise ProviderError(
|
||||
"UPSTREAM_ERROR",
|
||||
"Gitea returned a response that could not be parsed.",
|
||||
retryable=False,
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ProviderError(
|
||||
"UPSTREAM_ERROR", "Gitea returned an unexpected response shape.", retryable=False,
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
"""Provider boundary owned with search_project_knowledge."""
|
||||
"""Read-only project-knowledge adapter for search_project_knowledge.
|
||||
|
||||
Retrieval reuses what Cowork already owns rather than adding a vector store,
|
||||
an embedding pipeline, or a new RAG framework:
|
||||
|
||||
* core.projects already defines a project's *knowledge* as the files at its
|
||||
workspace root, and already confines one project's agent to that folder.
|
||||
That same folder is the only corpus this provider will ever read, which is
|
||||
what makes project isolation structural instead of a filter applied later.
|
||||
* core.doc_extract.extract_text already turns docx/pptx/xlsx/pdf/text into
|
||||
plain text for prompt building, so this provider inherits format support.
|
||||
|
||||
Ranking is a bounded lexical (term-overlap) scan over those files. It is a
|
||||
deliberate floor, not a claim of semantic search -- see the ponytail note on
|
||||
_score_chunk.
|
||||
|
||||
Target and access resolution stay separate here, exactly as in the issue
|
||||
provider, so a pilot workspace root can later become a served knowledge base
|
||||
without changing the tool or the provider contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..foundation import IdentityContext, ProviderError
|
||||
from ..foundation import IdentityContext, ProviderError, decode_offset_cursor
|
||||
|
||||
# ---- tunables (documented, not hardcoded secrets) -------------------------
|
||||
_PAGE_SIZE_BY_DETAIL = {"summary": 3, "standard": 5, "full": 10}
|
||||
_EXCERPT_CHARS_BY_DETAIL = {"summary": 200, "standard": 600, "full": 1200}
|
||||
_MAX_FILES_SCANNED = 200
|
||||
_MAX_FILE_BYTES = 2_000_000
|
||||
_MAX_CHARS_PER_DOCUMENT = 200_000
|
||||
_CHUNK_CHARS = 1_200
|
||||
_MAX_CANDIDATES = 500
|
||||
_MAX_QUERY_TERMS = 32
|
||||
|
||||
_KNOWLEDGE_SUFFIXES = frozenset({
|
||||
".md", ".markdown", ".txt", ".rst", ".csv", ".json", ".yaml", ".yml",
|
||||
".docx", ".docm", ".pptx", ".xlsx", ".xlsm", ".pdf", ".odt", ".odp", ".ods",
|
||||
})
|
||||
_WORD_PATTERN = re.compile(r"\w+", re.UNICODE)
|
||||
_HEADING_PATTERN = re.compile(r"^(#{1,6})[ \t]+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
class KnowledgeProvider(Protocol):
|
||||
@@ -33,6 +75,334 @@ class UnconfiguredKnowledgeProvider:
|
||||
)
|
||||
|
||||
|
||||
def build_provider(identity: IdentityContext) -> KnowledgeProvider:
|
||||
"""Replace only this factory when wiring approved project retrieval."""
|
||||
return UnconfiguredKnowledgeProvider()
|
||||
@dataclass(frozen=True)
|
||||
class _WorkspaceTarget:
|
||||
"""One project's approved knowledge root. The provider never reads outside it."""
|
||||
|
||||
root: Path
|
||||
project_id: str
|
||||
|
||||
|
||||
class KnowledgeTargetResolver(Protocol):
|
||||
def resolve(self, identity: IdentityContext) -> _WorkspaceTarget: ...
|
||||
|
||||
|
||||
class KnowledgeAccessResolver(Protocol):
|
||||
def resolve(self, identity: IdentityContext, target: _WorkspaceTarget) -> None: ...
|
||||
|
||||
|
||||
def _is_safe_segment(value: str) -> bool:
|
||||
return (
|
||||
bool(value)
|
||||
and value not in {".", ".."}
|
||||
and not set(value) & set("/\\")
|
||||
and "\x00" not in value
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectWorkspaceTargetResolver:
|
||||
"""Resolve the workspace root from the *identity*, never from the request.
|
||||
|
||||
project_id in the request is only ever verified against this result; it is
|
||||
never routing authority.
|
||||
"""
|
||||
|
||||
def resolve(self, identity: IdentityContext) -> _WorkspaceTarget:
|
||||
configured = os.environ.get("PROJECT_CONTEXT_KNOWLEDGE_ROOT", "").strip()
|
||||
if not configured:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"PROJECT_CONTEXT_KNOWLEDGE_ROOT is not configured for this environment.",
|
||||
retryable=False,
|
||||
)
|
||||
base = Path(configured).expanduser()
|
||||
# The identity's project name is a path *segment*, never a path, so a
|
||||
# traversal-shaped project can never escape the configured base.
|
||||
if not _is_safe_segment(identity.project):
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"This identity is not mapped to an approved knowledge workspace.",
|
||||
retryable=False,
|
||||
)
|
||||
try:
|
||||
resolved = (base / identity.project).resolve()
|
||||
resolved_base = base.resolve()
|
||||
except OSError as exc:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The approved knowledge workspace could not be opened.",
|
||||
retryable=False,
|
||||
) from exc
|
||||
if resolved_base not in resolved.parents or not resolved.is_dir():
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"This identity is not mapped to an approved knowledge workspace.",
|
||||
retryable=False,
|
||||
)
|
||||
return _WorkspaceTarget(root=resolved, project_id=identity.project)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalWorkspaceAccessResolver:
|
||||
"""Pilot access check for a local workspace root.
|
||||
|
||||
The local corpus needs no fetch credential, so this resolver only asserts
|
||||
the workspace is readable. It exists as its own seam so an on-behalf-of
|
||||
credential for a served knowledge base can replace it without touching the
|
||||
tool or the provider.
|
||||
"""
|
||||
|
||||
def resolve(self, identity: IdentityContext, target: _WorkspaceTarget) -> None:
|
||||
del identity
|
||||
if not os.access(target.root, os.R_OK):
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The approved knowledge workspace is not readable.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
|
||||
def build_provider(
|
||||
identity: IdentityContext,
|
||||
*,
|
||||
target_resolver: KnowledgeTargetResolver | None = None,
|
||||
access_resolver: KnowledgeAccessResolver | None = None,
|
||||
) -> KnowledgeProvider:
|
||||
"""Compose routing and access only after the policy has allowed the call."""
|
||||
target = (target_resolver or ProjectWorkspaceTargetResolver()).resolve(identity)
|
||||
(access_resolver or LocalWorkspaceAccessResolver()).resolve(identity, target)
|
||||
return WorkspaceKnowledgeProvider(target)
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
return unicodedata.normalize("NFKC", text).casefold()
|
||||
|
||||
|
||||
def _terms(text: str) -> list[str]:
|
||||
return _WORD_PATTERN.findall(_normalize(text))[:_MAX_QUERY_TERMS]
|
||||
|
||||
|
||||
class WorkspaceKnowledgeProvider:
|
||||
"""Ranked, bounded, read-only lexical search over ONE project's workspace."""
|
||||
|
||||
def __init__(self, target: _WorkspaceTarget, *, extractor: Any = None) -> None:
|
||||
self._target = target
|
||||
self._extractor = extractor
|
||||
|
||||
def search_knowledge(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
query: str,
|
||||
detail: str,
|
||||
top_k: int,
|
||||
language: str | None = None,
|
||||
cursor: str | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
del language # accepted by the contract; the lexical scan is language-neutral
|
||||
if project_id != self._target.project_id:
|
||||
# Defense in depth: the runtime's policy already guarantees this
|
||||
# (DENIED fires first), but the provider never trusts
|
||||
# caller-supplied routing regardless.
|
||||
raise ProviderError(
|
||||
"INTERNAL",
|
||||
"Resolved provider does not match the requested project.",
|
||||
retryable=False,
|
||||
)
|
||||
terms = _terms(query)
|
||||
if not terms:
|
||||
# Whitespace/punctuation-only queries pass the contract's length
|
||||
# bound but carry no search intent -- reject before any file read.
|
||||
raise ProviderError(
|
||||
"INVALID_INPUT",
|
||||
"query must contain at least one searchable term.",
|
||||
retryable=False,
|
||||
)
|
||||
offset = decode_offset_cursor(cursor)
|
||||
|
||||
scored = self._scan(terms)
|
||||
page_size = min(_PAGE_SIZE_BY_DETAIL.get(detail, 5), top_k)
|
||||
excerpt_chars = _EXCERPT_CHARS_BY_DETAIL.get(detail, 600)
|
||||
|
||||
page = scored[offset : offset + page_size]
|
||||
remaining = max(0, len(scored) - (offset + page_size))
|
||||
truncated = remaining > 0
|
||||
retrieved_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
items = tuple(
|
||||
{
|
||||
"document_id": hit["document_id"],
|
||||
"chunk_id": hit["chunk_id"],
|
||||
"title": hit["title"][:200],
|
||||
"excerpt": hit["text"][:excerpt_chars],
|
||||
"score": hit["score"],
|
||||
"source": {
|
||||
"system": "cowork-workspace",
|
||||
"url": hit["url"],
|
||||
"revision": hit["revision"],
|
||||
"retrieved_at": retrieved_at,
|
||||
},
|
||||
}
|
||||
for hit in page
|
||||
)
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"query": query,
|
||||
"items": items,
|
||||
"truncated": truncated,
|
||||
"returned": len(items),
|
||||
"remaining": remaining,
|
||||
"next_cursor": str(offset + page_size) if truncated else None,
|
||||
}
|
||||
|
||||
# ---- internals ---------------------------------------------------
|
||||
def _scan(self, terms: list[str]) -> list[dict[str, Any]]:
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for path in self._knowledge_files():
|
||||
text = self._read(path)
|
||||
if not text:
|
||||
continue
|
||||
document_id = path.relative_to(self._target.root).as_posix()
|
||||
revision = self._revision(path)
|
||||
url = path.as_uri()
|
||||
for index, (heading, chunk) in enumerate(_chunk(text)):
|
||||
score = _score_chunk(chunk, heading, document_id, terms)
|
||||
if score <= 0:
|
||||
continue
|
||||
candidates.append({
|
||||
"document_id": document_id,
|
||||
"chunk_id": f"{document_id}#{index}",
|
||||
"title": heading or path.name,
|
||||
"text": chunk.strip(),
|
||||
"score": score,
|
||||
"url": url,
|
||||
"revision": revision,
|
||||
})
|
||||
if len(candidates) >= _MAX_CANDIDATES:
|
||||
break
|
||||
if len(candidates) >= _MAX_CANDIDATES:
|
||||
break
|
||||
# Deterministic order: best score first, then a stable identity tiebreak
|
||||
# so pagination cursors stay meaningful across calls.
|
||||
candidates.sort(key=lambda hit: (-hit["score"], hit["chunk_id"]))
|
||||
return candidates
|
||||
|
||||
def _knowledge_files(self) -> list[Path]:
|
||||
try:
|
||||
entries = sorted(
|
||||
p for p in self._target.root.rglob("*")
|
||||
if p.is_file() and p.suffix.lower() in _KNOWLEDGE_SUFFIXES
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"The approved knowledge workspace could not be listed.",
|
||||
retryable=False,
|
||||
) from exc
|
||||
approved: list[Path] = []
|
||||
for path in entries:
|
||||
# A symlink can point outside the workspace: resolve and re-check
|
||||
# containment so project isolation survives a planted link.
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if self._target.root not in resolved.parents:
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_size > _MAX_FILE_BYTES:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
approved.append(path)
|
||||
if len(approved) >= _MAX_FILES_SCANNED:
|
||||
break
|
||||
return approved
|
||||
|
||||
def _read(self, path: Path) -> str:
|
||||
extractor = self._extractor or _default_extractor()
|
||||
try:
|
||||
text, _note = extractor(path)
|
||||
except Exception: # noqa: BLE001 - one unreadable document must not fail the search
|
||||
return ""
|
||||
return (text or "")[:_MAX_CHARS_PER_DOCUMENT]
|
||||
|
||||
def _revision(self, path: Path) -> str:
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return "unknown"
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
return f"mtime:{modified};size:{stat.st_size}"
|
||||
|
||||
|
||||
def _default_extractor():
|
||||
"""Reuse Cowork's existing text extraction; fall back to plain-text reads.
|
||||
|
||||
The fallback keeps the MCP server importable as a standalone process (the
|
||||
app package pulls in UI-oriented dependencies) without duplicating any of
|
||||
the format handling when the app package is present.
|
||||
"""
|
||||
try:
|
||||
from ....core.doc_extract import extract_text
|
||||
except Exception: # noqa: BLE001 - standalone server run outside the app package
|
||||
def _plain(path: Path) -> tuple[str | None, str]:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace"), ""
|
||||
except OSError as exc:
|
||||
return None, f"could not read ({exc})"
|
||||
return _plain
|
||||
return lambda path: extract_text(path)
|
||||
|
||||
|
||||
def _chunk(text: str) -> list[tuple[str, str]]:
|
||||
"""Split a document into (heading, body) chunks.
|
||||
|
||||
Markdown headings give a citable section; 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: str, heading: str, document_id: str, terms: list[str]) -> float:
|
||||
"""Term-coverage score in [0, 1], weighted toward heading/title matches.
|
||||
|
||||
ponytail: lexical term overlap, not embeddings. It needs no index, no
|
||||
model, and no new dependency, and it is honest about what it is -- the
|
||||
score is coverage, never a fabricated similarity. Upgrade path: swap this
|
||||
one function for a Cowork-provided semantic ranker when the project corpus
|
||||
is large enough that recall (not plumbing) is the bottleneck.
|
||||
"""
|
||||
body = _normalize(chunk)
|
||||
label = _normalize(f"{heading} {document_id}")
|
||||
matched = 0
|
||||
weighted = 0.0
|
||||
for term in 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(terms)
|
||||
emphasis = weighted / len(terms)
|
||||
# Bounded to the contract's [0, 1] score range.
|
||||
return round(min(1.0, 0.7 * coverage + 0.3 * emphasis), 4)
|
||||
|
||||
Reference in New Issue
Block a user