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