update project knowledge function
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"""Canonical Jira knowledge domain models.
|
||||
|
||||
This package owns the normalization of raw Jira issues into Cowork's canonical
|
||||
Project Knowledge representation and the persistence of sync state. It has no
|
||||
dependency on MCP, Qt, or any transport layer — pure Python dataclasses with
|
||||
atomic JSON I/O only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .canonical_issue import CanonicalJiraIssue, normalize_jira_issue
|
||||
from .sync_state import SyncManifest, load_manifest, save_manifest
|
||||
|
||||
__all__ = [
|
||||
"CanonicalJiraIssue",
|
||||
"normalize_jira_issue",
|
||||
"SyncManifest",
|
||||
"load_manifest",
|
||||
"save_manifest",
|
||||
]
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Canonical Jira issue representation for Project Knowledge.
|
||||
|
||||
Normalizes raw Jira REST API JSON into a stable, source-agnostic document that
|
||||
the retrieval layer can index and search without knowing Jira-specific field
|
||||
names. Every normalized issue carries mandatory provenance so search results
|
||||
can cite the exact Jira source.
|
||||
|
||||
Design constraints (from the production prompt):
|
||||
- Stable knowledge identity derived from the Jira issue key.
|
||||
- Project/tenant scope using Cowork's existing canonical model.
|
||||
- Truthful source updated/revision semantics — no fake revisions.
|
||||
- Handles empty description, long content, Jira markup, missing custom fields.
|
||||
- Does not hardcode one customer's Jira schema into the global model.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# Bounded content size to prevent a single issue from dominating the index or
|
||||
# the retrieval context window. Matches the workspace provider's per-document cap.
|
||||
_MAX_CONTENT_CHARS = 200_000
|
||||
_MAX_DESCRIPTION_CHARS = 50_000
|
||||
|
||||
# Jira wiki markup / HTML patterns stripped during normalization.
|
||||
_JIRA_LINK_PATTERN = re.compile(r"\[([^\]]+)\|([^\]]+)\]")
|
||||
_HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
|
||||
_MULTI_SPACE_PATTERN = re.compile(r"[ \t]{2,}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraProvenance:
|
||||
"""Mandatory source traceability for every canonical issue.
|
||||
|
||||
Every field is required so a search result can always answer: where did
|
||||
this come from, which version, and when was it retrieved?
|
||||
"""
|
||||
system: str = "jira"
|
||||
issue_key: str = ""
|
||||
project_key: str = ""
|
||||
source_url: str = ""
|
||||
source_updated: str = ""
|
||||
issue_type: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonicalJiraIssue:
|
||||
"""Source-agnostic document ready for indexing and retrieval.
|
||||
|
||||
The identity is ``<project_key>/<issue_key>`` — stable across syncs and
|
||||
safe as a filename stem. Content is pre-normalized plain text; Jira markup
|
||||
and HTML are stripped during construction.
|
||||
"""
|
||||
knowledge_id: str
|
||||
project_id: str
|
||||
title: str
|
||||
content: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
provenance: JiraProvenance = field(default_factory=JiraProvenance)
|
||||
ingested_at: str = ""
|
||||
|
||||
def chunk_text(self) -> str:
|
||||
"""The searchable text: title + content, bounded."""
|
||||
combined = f"{self.title}\n\n{self.content}".strip()
|
||||
return combined[:_MAX_CONTENT_CHARS]
|
||||
|
||||
|
||||
def _strip_jira_markup(text: str) -> str:
|
||||
"""Remove Jira wiki markup links and HTML tags, collapse whitespace."""
|
||||
if not text:
|
||||
return ""
|
||||
# Convert [label|url] → label
|
||||
cleaned = _JIRA_LINK_PATTERN.sub(r"\1", text)
|
||||
# Strip remaining HTML tags
|
||||
cleaned = _HTML_TAG_PATTERN.sub(" ", cleaned)
|
||||
# Collapse runs of whitespace
|
||||
cleaned = _MULTI_SPACE_PATTERN.sub(" ", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _safe_str(value: Any, max_chars: int = 0) -> str:
|
||||
"""Coerce a Jira field value to a bounded string."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, dict):
|
||||
# ADF rich-text descriptions arrive as dicts; surface a placeholder.
|
||||
return "(rich-text description — open in Jira)"
|
||||
text = str(value).strip()
|
||||
if max_chars > 0:
|
||||
return text[:max_chars]
|
||||
return text
|
||||
|
||||
|
||||
def _build_source_url(base_url: str, issue_key: str) -> str:
|
||||
"""Construct the browse URL for an issue key."""
|
||||
base = (base_url or "").rstrip("/")
|
||||
if not base or not issue_key:
|
||||
return ""
|
||||
return f"{base}/browse/{issue_key}"
|
||||
|
||||
|
||||
def normalize_jira_issue(
|
||||
raw: Dict[str, Any],
|
||||
*,
|
||||
project_id: str,
|
||||
jira_base_url: str = "",
|
||||
) -> CanonicalJiraIssue:
|
||||
"""Turn a raw Jira REST API issue dict into a canonical knowledge document.
|
||||
|
||||
Args:
|
||||
raw: The JSON object from ``/rest/api/2/issue/{key}``.
|
||||
project_id: Cowork project identifier this issue belongs to.
|
||||
jira_base_url: Base URL of the Jira instance (for provenance URLs).
|
||||
|
||||
Returns:
|
||||
A frozen ``CanonicalJiraIssue`` with mandatory provenance.
|
||||
|
||||
Raises:
|
||||
ValueError: When the raw payload lacks the minimum fields needed to
|
||||
produce a stable identity (``key`` at the top level).
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("raw issue must be a dict")
|
||||
|
||||
issue_key = _safe_str(raw.get("key"))
|
||||
if not issue_key:
|
||||
raise ValueError("raw issue missing 'key'")
|
||||
|
||||
fields = raw.get("fields") or {}
|
||||
if not isinstance(fields, dict):
|
||||
fields = {}
|
||||
|
||||
summary = _safe_str(fields.get("summary"))
|
||||
description_raw = fields.get("description")
|
||||
description = _strip_jira_markup(_safe_str(description_raw, _MAX_DESCRIPTION_CHARS))
|
||||
|
||||
issue_type_obj = fields.get("issuetype") or {}
|
||||
issue_type = _safe_str(issue_type_obj.get("name")) if isinstance(issue_type_obj, dict) else ""
|
||||
|
||||
status_obj = fields.get("status") or {}
|
||||
status = _safe_str(status_obj.get("name")) if isinstance(status_obj, dict) else ""
|
||||
|
||||
labels = list(fields.get("labels") or [])
|
||||
components = [
|
||||
_safe_str(c.get("name"))
|
||||
for c in (fields.get("components") or [])
|
||||
if isinstance(c, dict)
|
||||
]
|
||||
|
||||
# Acceptance criteria: check common custom field names and heading-based extraction.
|
||||
acceptance = ""
|
||||
for ac_field in ("customfield_10016", "acceptance_criteria", "customfield_10001"):
|
||||
ac_val = fields.get(ac_field)
|
||||
if ac_val and isinstance(ac_val, str) and ac_val.strip():
|
||||
acceptance = _strip_jira_markup(ac_val)[:5000]
|
||||
break
|
||||
if not acceptance and description:
|
||||
# Try extracting from a markdown-style heading in the description.
|
||||
ac_match = re.search(
|
||||
r"(?:^|\n)#{1,6}\s+(?:Acceptance Criteria|Tiêu chí hoàn thành|Tiêu chí chấp nhận)\s*\n(.*?)(?=\n#{1,6}\s|\Z)",
|
||||
description,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if ac_match:
|
||||
acceptance = ac_match.group(1).strip()[:5000]
|
||||
|
||||
# Linked issues (outward links only, bounded).
|
||||
linked: List[str] = []
|
||||
for link_group in (fields.get("issuelinks") or [])[:20]:
|
||||
if not isinstance(link_group, dict):
|
||||
continue
|
||||
outward = link_group.get("outwardIssue") or link_group.get("inwardIssue")
|
||||
if isinstance(outward, dict) and outward.get("key"):
|
||||
linked.append(str(outward["key"]))
|
||||
|
||||
updated = _safe_str(fields.get("updated"))
|
||||
created = _safe_str(fields.get("created"))
|
||||
|
||||
# Project key from the issue itself (e.g. "ABX" from "ABX-123").
|
||||
project_key = issue_key.rsplit("-", 1)[0] if "-" in issue_key else ""
|
||||
|
||||
# Build the searchable content block.
|
||||
content_parts = []
|
||||
if description:
|
||||
content_parts.append(description)
|
||||
if acceptance:
|
||||
content_parts.append(f"Acceptance Criteria:\n{acceptance}")
|
||||
if labels:
|
||||
content_parts.append(f"Labels: {', '.join(labels)}")
|
||||
if components:
|
||||
content_parts.append(f"Components: {', '.join(components)}")
|
||||
if linked:
|
||||
content_parts.append(f"Linked Issues: {', '.join(linked[:10])}")
|
||||
content = "\n\n".join(content_parts)[:_MAX_CONTENT_CHARS]
|
||||
|
||||
knowledge_id = f"{project_key}/{issue_key}" if project_key else issue_key
|
||||
source_url = _build_source_url(jira_base_url, issue_key)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"issue_type": issue_type,
|
||||
"status": status,
|
||||
"labels": labels,
|
||||
"components": components,
|
||||
"linked_issues": linked[:10],
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
}
|
||||
if acceptance:
|
||||
metadata["has_acceptance_criteria"] = True
|
||||
|
||||
return CanonicalJiraIssue(
|
||||
knowledge_id=knowledge_id,
|
||||
project_id=project_id,
|
||||
title=summary or issue_key,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
provenance=JiraProvenance(
|
||||
system="jira",
|
||||
issue_key=issue_key,
|
||||
project_key=project_key,
|
||||
source_url=source_url,
|
||||
source_updated=updated,
|
||||
issue_type=issue_type,
|
||||
status=status,
|
||||
),
|
||||
ingested_at=now,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CanonicalJiraIssue",
|
||||
"JiraProvenance",
|
||||
"normalize_jira_issue",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Sync state persistence for Jira Project Knowledge.
|
||||
|
||||
A ``SyncManifest`` records the operational state of one project's Jira sync:
|
||||
when it last succeeded, how many issues were processed or failed, and the
|
||||
incremental checkpoint (Jira ``updated > timestamp``) for the next run.
|
||||
|
||||
Persistence uses atomic JSON writes so a crash mid-sync cannot corrupt the
|
||||
manifest and cause duplicate or lost work on recovery.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncManifest:
|
||||
"""Operational state of one project's Jira knowledge sync.
|
||||
|
||||
All timestamps are ISO-8601 UTC strings. ``sync_cursor`` is the Jira
|
||||
``updated`` timestamp watermark; the next incremental sync fetches issues
|
||||
with ``updated >= sync_cursor``.
|
||||
"""
|
||||
project_id: str
|
||||
jira_project_key: str = ""
|
||||
last_successful_sync: str = ""
|
||||
last_attempted_sync: str = ""
|
||||
sync_cursor: str = ""
|
||||
processed_count: int = 0
|
||||
failed_count: int = 0
|
||||
error_category: str = ""
|
||||
total_issues_indexed: int = 0
|
||||
sync_duration_seconds: float = 0.0
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def mark_attempt(self) -> None:
|
||||
"""Record that a sync attempt has started."""
|
||||
self.last_attempted_sync = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def mark_success(
|
||||
self,
|
||||
*,
|
||||
processed: int,
|
||||
failed: int,
|
||||
cursor: str,
|
||||
duration: float,
|
||||
total_indexed: int,
|
||||
) -> None:
|
||||
"""Record a completed sync with its outcomes."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self.last_successful_sync = now
|
||||
self.last_attempted_sync = now
|
||||
self.processed_count = processed
|
||||
self.failed_count = failed
|
||||
self.sync_cursor = cursor
|
||||
self.sync_duration_seconds = round(duration, 2)
|
||||
self.total_issues_indexed = total_indexed
|
||||
self.error_category = ""
|
||||
|
||||
def mark_failure(self, category: str, failed: int = 0) -> None:
|
||||
"""Record a failed sync attempt without losing the previous cursor."""
|
||||
self.last_attempted_sync = datetime.now(timezone.utc).isoformat()
|
||||
self.error_category = category
|
||||
if failed:
|
||||
self.failed_count = failed
|
||||
|
||||
|
||||
def _manifest_path(index_root: Path, project_id: str) -> Path:
|
||||
"""Deterministic manifest path for one project."""
|
||||
safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in project_id)
|
||||
return index_root / safe / "manifest.json"
|
||||
|
||||
|
||||
def load_manifest(index_root: Path, project_id: str) -> SyncManifest:
|
||||
"""Load the manifest for ``project_id``, returning a fresh one if absent.
|
||||
|
||||
Never raises on missing or corrupt files — a missing manifest simply means
|
||||
"first sync", and a corrupt one is treated the same way (the operator can
|
||||
inspect the file manually if needed).
|
||||
"""
|
||||
path = _manifest_path(index_root, project_id)
|
||||
if not path.exists():
|
||||
return SyncManifest(project_id=project_id)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
known = {f.name for f in SyncManifest.__dataclass_fields__.values()}
|
||||
return SyncManifest(**{k: v for k, v in data.items() if k in known})
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
return SyncManifest(project_id=project_id)
|
||||
|
||||
|
||||
def save_manifest(index_root: Path, manifest: SyncManifest) -> None:
|
||||
"""Atomically persist ``manifest`` to disk.
|
||||
|
||||
Creates the project directory if it does not exist. Uses the shared
|
||||
atomic-write helper so a crash between truncate and write cannot leave
|
||||
a half-written manifest.
|
||||
"""
|
||||
path = _manifest_path(index_root, manifest.project_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
from ...infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, asdict(manifest))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SyncManifest",
|
||||
"load_manifest",
|
||||
"save_manifest",
|
||||
]
|
||||
Reference in New Issue
Block a user