diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..a3a48c3de6 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# DAK Skill Library — local config. Copy to .env (never commit .env). +# +# LLM features (authoring, error interpretation, classification): +# Leave blank → LLM steps skipped, structural validation still runs. +# Billed to YOUR account, not WHO. +# +# ─── API KEY ──────────────────────────────────────────────────────────── +# Get a key from your LLM provider: +# OpenAI: https://platform.openai.com/api-keys → starts with sk- +# Anthropic: https://console.anthropic.com/settings/keys → starts with sk-ant- +# Google AI: https://aistudio.google.com/app/apikey → starts with AI... +# Azure: Azure Portal → your OpenAI resource → Keys +# +# LiteLLM routes to the right provider based on the model name below. +# Leave blank to skip all LLM steps (structural validation still runs). +DAK_LLM_API_KEY= + +# ─── MODEL ────────────────────────────────────────────────────────────── +# LiteLLM model identifier. Format: [provider/]model-name +# +# Popular options (as of 2025): +# OpenAI: gpt-4o, gpt-4o-mini, gpt-4-turbo, o1-mini +# Anthropic: claude-sonnet-4-20250514, claude-3-5-haiku-20241022 +# Google: gemini/gemini-2.0-flash, gemini/gemini-1.5-pro +# Azure: azure/your-deployment-name +# +# Master list of all supported models and provider prefixes: +# https://docs.litellm.ai/docs/providers +# +# Default: gpt-4o (requires OpenAI key above) +DAK_LLM_MODEL=gpt-4o + +# ─── IG PUBLISHER (optional) ─────────────────────────────────────────── +# Custom FHIR terminology server. Leave blank for default (tx.fhir.org). +DAK_TX_SERVER= diff --git a/.github/skills/Dockerfile b/.github/skills/Dockerfile new file mode 100644 index 0000000000..0dd1549794 --- /dev/null +++ b/.github/skills/Dockerfile @@ -0,0 +1,46 @@ +# DAK Skill Library — Local Development Image +# Mirrors ghbuild.yml CI environment exactly. +# Base: hl7fhir/ig-publisher-base (Jekyll, Ruby, Java 17, Node.js) + +FROM hl7fhir/ig-publisher-base:latest + +LABEL org.opencontainers.image.title="DAK Skill Library" +LABEL org.opencontainers.image.source="https://github.com/WorldHealthOrganization/smart-base" + +# Python packages — identical to ghbuild.yml +# --break-system-packages is required because the base image uses Debian's +# externally-managed Python; a venv is unnecessary inside a disposable container. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && pip3 install --break-system-packages \ + "GitPython>=3.1.40" \ + "PyYAML>=6.0" \ + "requests>=2.28.0" \ + "lxml" \ + "litellm>=1.0.0" \ + "pdfplumber" \ + "pandas" \ + && rm -rf /var/lib/apt/lists/* + +# SUSHI — identical to ghbuild.yml +RUN npm install -g fsh-sushi + +# IG Publisher jar — pre-baked so local runs don't need network +# Override: -v /local/publisher.jar:/app/publisher.jar +RUN mkdir -p /app/input-cache \ + && curl -L \ + https://github.com/HL7/fhir-ig-publisher/releases/latest/download/publisher.jar \ + -o /app/input-cache/publisher.jar + +# DAK skill library +COPY . /app/skills/ + +# Workspace — mount IG repo here: -v $(pwd):/workspace +WORKDIR /workspace + +ENV PUBLISHER_JAR=/app/input-cache/publisher.jar +ENV DAK_IG_ROOT=/workspace + +ENTRYPOINT ["python3", "/app/skills/cli/dak_skill.py"] +CMD ["--help"] diff --git a/.github/skills/README.md b/.github/skills/README.md new file mode 100644 index 0000000000..3ae56b4ad5 --- /dev/null +++ b/.github/skills/README.md @@ -0,0 +1,120 @@ +# DAK Skill Library + +The DAK Skill Library provides AI-assisted and structural validation tools +for authoring WHO Digital Adaptation Kit (DAK) content. + +## Quick Start + +### Local Development (Docker) + +```bash +# 1. Build the image +docker build -t dak-skill .github/skills/ + +# 2. Copy environment template +cp .env.example .env +# Edit .env to add your LLM API key (optional — structural validation works without it) + +# 3. Run skills +docker compose -f .github/skills/docker-compose.yml run --rm validate +docker compose -f .github/skills/docker-compose.yml run --rm validate-ig +docker compose -f .github/skills/docker-compose.yml run --rm import-bpmn +docker compose -f .github/skills/docker-compose.yml run --rm shell + +# Shortcut alias: +alias dak='docker compose -f .github/skills/docker-compose.yml run --rm' +dak validate +dak import-bpmn +``` + +### CI (GitHub Actions) + +Skills run automatically via GitHub Actions workflows: + +| Trigger | Workflow | What it does | +|---|---|---| +| Issue opened/edited | `classify-issue.yml` | Auto-labels issues with `content:L1/L2/L3/translation` | +| Label `content:L1` | `skill-l1-review.yml` | L1 guideline review (placeholder) | +| Label `content:L2` | `skill-l2-dak.yml` | L2 DAK content authoring | +| Label `content:L3` | `skill-l3-review.yml` | L3 adaptation review (placeholder) | +| Label `content:translation` | `skill-translation.yml` | Translation management (placeholder) | +| PR comment `/validate` | `pr-validate-slash.yml` | Structural + IG validation | + +## One-Time Repository Setup + +``` +1. Create labels (Issues → Labels → New label): + content:L1 #0075ca "WHO source guideline content" + content:L2 #e4e669 "DAK FHIR assets" + content:L3 #d73a4a "Implementation adaptations" + content:translation #0e8a16 "Translation of any content layer" + (Label definitions also stored in .github/skills/labels/*.json for reference.) + +2. Add secret (Settings → Secrets and variables → Actions → New repository secret): + DAK_LLM_API_KEY = sk-... + +3. Add variable (Settings → Secrets and variables → Variables → New variable): + DAK_LLM_MODEL = gpt-4o (or gpt-4o-mini to reduce cost) + See .env.example for the full list of supported model identifiers, + or https://docs.litellm.ai/docs/providers for the master list. + +4. Build local Docker image (optional, for local development): + docker build -t dak-skill .github/skills/ +``` + +## Security Model + +- **API keys MUST NOT appear** in dispatch inputs, issue comments, PR comments, or any user-visible UI +- Two legitimate locations only: **repo secret** (CI) or **local `.env` file** (Docker/local) +- LLM steps skip gracefully when no key present — non-LLM validation always runs +- **Zero WHO infrastructure cost; zero WHO AI cost** + +### Graceful Degradation + +| Skill | No key | With key | +|---|---|---| +| BPMN structure validation | ✅ runs | ✅ runs | +| Swimlane ↔ ActorDef validation | ✅ runs | ✅ runs | +| IG Publisher build/validate | ✅ runs | ✅ runs | +| Issue classification | keyword fallback | LLM classification | +| LLM BPMN authoring | ⚠️ skipped | ✅ runs | +| LLM error interpretation | ⚠️ skipped | ✅ runs | + +## Directory Structure + +``` +.github/skills/ +├── Dockerfile # FROM hl7fhir/ig-publisher-base — mirrors CI +├── docker-compose.yml # Service aliases: validate, author, import, shell +├── README.md # This file +├── skills_registry.yaml # All registered skills +├── cli/ +│ └── dak_skill.py # CLI entry point +├── common/ +│ ├── llm_utils.py # LLM helpers — thin wrappers around LiteLLM +│ ├── prompt_loader.py # load_prompt() — .md templates with {variable} +│ ├── ig_errors.py # FATAL/ERROR/WARNING/INFORMATION format +│ ├── fsh_utils.py # FSH file utilities +│ ├── ig_publisher_iface.py +│ └── prompts/ # Shared prompt templates +├── bpmn_author/ # Author/edit BPMN +├── bpmn_import/ # Import BPMN → FSH, validate lanes +├── ig_publisher/ # IG Publisher validation and build +├── dak_authoring/ # Issue classification and L2 content review/authoring +│ ├── actions/ +│ │ ├── classify_issue_action.py # Keyword + LLM issue classifier +│ │ └── dak_authoring_action.py # L2 content review skill (→ content:L2 label) +│ └── prompts/ +├── labels/ # GitHub label definitions (JSON, for reference) +├── l1_review/ # (placeholder v0.2) +├── l3_review/ # (placeholder v0.3) +└── translation/ # (placeholder v0.3) +``` + +## LLM Provider + +LLM features use [LiteLLM](https://github.com/BerriAI/litellm) (MIT License) — +a well-maintained multi-provider library (OpenAI, Anthropic, Google, etc.) +with 20k+ GitHub stars. The `common/llm_utils.py` module adds only DAK-specific +environment variable bridging and JSON-extraction helpers on top of LiteLLM; +there is no custom LLM facade to maintain. diff --git a/.github/skills/bpmn_author/__init__.py b/.github/skills/bpmn_author/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/bpmn_author/actions/__init__.py b/.github/skills/bpmn_author/actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/bpmn_author/actions/bpmn_author_action.py b/.github/skills/bpmn_author/actions/bpmn_author_action.py new file mode 100644 index 0000000000..3850bf519b --- /dev/null +++ b/.github/skills/bpmn_author/actions/bpmn_author_action.py @@ -0,0 +1,73 @@ +""" +BPMN Author action — creates or edits BPMN files via LLM, +then validates the result structurally. + +Environment variables: + DAK_LLM_API_KEY — LLM API key (optional; LLM steps skipped if absent) + DAK_LLM_MODEL — LLM model name (default: gpt-4o) + GITHUB_TOKEN — GitHub API token for issue/PR interaction + ISSUE_NUMBER — GitHub issue number + ISSUE_TITLE — Issue title + ISSUE_BODY — Issue body text +""" + +import os +import sys +from pathlib import Path + +# Ensure the skills root is on sys.path +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + +from common.ig_errors import format_issues, has_errors +from bpmn_author.validators.bpmn_xml_validator import validate_bpmn_xml +from bpmn_author.validators.swimlane_validator import validate_swimlanes + + +def main() -> None: + api_key = os.environ.get("DAK_LLM_API_KEY", "") + if not api_key: + print("⚠️ DAK_LLM_API_KEY not set — LLM step skipped (structural validation still runs)") + sys.exit(0) + + from common.llm_utils import dak_completion + from common.prompt_loader import load_prompt + + issue_title = os.environ.get("ISSUE_TITLE", "") + issue_body = os.environ.get("ISSUE_BODY", "") + model = os.environ.get("DAK_LLM_MODEL", "gpt-4o") + + # Load additional prompt components required by the create_or_edit_bpmn template + _prompts_dir = _SKILLS_ROOT / "common" / "prompts" + dak_bpmn_constraints = (_prompts_dir / "dak_bpmn_constraints.md").read_text(encoding="utf-8") + bpmn_xml_schema = (_prompts_dir / "bpmn_xml_schema.md").read_text(encoding="utf-8") + actor_context = (_prompts_dir / "actor_context.md").read_text(encoding="utf-8") + + prompt = load_prompt( + "bpmn_author", "create_or_edit_bpmn", + user_request=f"{issue_title}\n\n{issue_body}", + current_bpmn="(none — creating new BPMN)", + dak_bpmn_constraints=dak_bpmn_constraints, + bpmn_xml_schema=bpmn_xml_schema, + actor_context=actor_context, + ) + + print(f"🤖 Requesting BPMN from {model}...") + bpmn_xml = dak_completion(prompt, api_key=api_key, model=model) + + # Validate the generated BPMN + issues = validate_bpmn_xml(bpmn_xml, filename="generated.bpmn") + issues.extend(validate_swimlanes(bpmn_xml, filename="generated.bpmn")) + + print(format_issues(issues)) + + if has_errors(issues): + print("❌ Generated BPMN has validation errors.") + sys.exit(1) + + print("✅ Generated BPMN passed structural validation.") + + +if __name__ == "__main__": + main() diff --git a/.github/skills/bpmn_author/prompts/create_or_edit_bpmn.md b/.github/skills/bpmn_author/prompts/create_or_edit_bpmn.md new file mode 100644 index 0000000000..75d47a6cc3 --- /dev/null +++ b/.github/skills/bpmn_author/prompts/create_or_edit_bpmn.md @@ -0,0 +1,33 @@ +# Create or Edit BPMN + +You are a BPMN 2.0 authoring assistant for WHO Digital Adaptation Kits (DAKs). + +## Your Task + +{user_request} + +## Constraints + +{dak_bpmn_constraints} + +## BPMN XML Schema + +{bpmn_xml_schema} + +## Actor Context + +{actor_context} + +## Current BPMN (if editing) + +```xml +{current_bpmn} +``` + +## Instructions + +1. Generate valid BPMN 2.0 XML following the constraints above. +2. Use meaningful lane IDs that can serve as FSH instance identifiers. +3. Ensure every task is assigned to exactly one lane. +4. Include sequence flows connecting all elements. +5. Return ONLY the BPMN XML — no explanation, no markdown fences. diff --git a/.github/skills/bpmn_author/prompts/validate_bpmn.md b/.github/skills/bpmn_author/prompts/validate_bpmn.md new file mode 100644 index 0000000000..fdc16eb91c --- /dev/null +++ b/.github/skills/bpmn_author/prompts/validate_bpmn.md @@ -0,0 +1,31 @@ +# Validate BPMN + +Review the following BPMN XML for compliance with WHO DAK constraints. + +## BPMN XML + +```xml +{bpmn_xml} +``` + +## Validation Results (structural) + +{validation_results} + +## Instructions + +Summarize the validation findings. For each issue: +1. Explain what is wrong and why it matters for DAK compliance. +2. Suggest a specific fix. + +If there are no issues, confirm the BPMN is valid. +Return your analysis as JSON: +```json +{{ + "valid": true/false, + "summary": "...", + "issues": [ + {{"code": "...", "severity": "...", "message": "...", "fix": "..."}} + ] +}} +``` diff --git a/.github/skills/bpmn_author/skills.yaml b/.github/skills/bpmn_author/skills.yaml new file mode 100644 index 0000000000..6e007098b3 --- /dev/null +++ b/.github/skills/bpmn_author/skills.yaml @@ -0,0 +1,23 @@ +# bpmn_author skill +name: bpmn_author +version: "0.1.0" +description: Author and edit standard BPMN 2.0 XML for DAK business processes + +commands: + - name: create-bpmn + description: Create a new BPMN file from a natural-language description + requires_llm: true + - name: edit-bpmn + description: Edit an existing BPMN file based on instructions + requires_llm: true + - name: validate-bpmn + description: Validate BPMN structure and DAK constraints (no LLM needed) + requires_llm: false + +validators: + - bpmn_xml_validator + - swimlane_validator + +prompts: + - create_or_edit_bpmn + - validate_bpmn diff --git a/.github/skills/bpmn_author/validators/__init__.py b/.github/skills/bpmn_author/validators/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/bpmn_author/validators/bpmn_xml_validator.py b/.github/skills/bpmn_author/validators/bpmn_xml_validator.py new file mode 100644 index 0000000000..338c1a2b03 --- /dev/null +++ b/.github/skills/bpmn_author/validators/bpmn_xml_validator.py @@ -0,0 +1,90 @@ +""" +BPMN XML structural validator. + +Validates that a BPMN file is well-formed XML, uses standard BPMN 2.0 +namespaces (no Zeebe/Camunda extensions), and follows basic structural rules. +""" + +from typing import List +from lxml import etree + +from common.ig_errors import Issue, error, warning, info + +BPMN_NS = "http://www.omg.org/spec/BPMN/20100524/MODEL" + +# Vendor namespaces that must NOT appear in DAK BPMN +_FORBIDDEN_NAMESPACES = { + "http://camunda.org/schema/zeebe/1.0": "Zeebe", + "http://camunda.org/schema/1.0/bpmn": "Camunda", + "http://camunda.org/schema/modeler/1.0": "Camunda Modeler", +} + + +def validate_bpmn_xml(bpmn_content: str, *, filename: str = "unknown.bpmn") -> List[Issue]: + """Validate BPMN XML content and return a list of issues. + + Checks: + 1. Well-formed XML + 2. Root element is bpmn:definitions + 3. No forbidden vendor namespaces + 4. At least one process element + 5. No duplicate id attributes + """ + issues: List[Issue] = [] + + # 1. Well-formed XML + try: + tree = etree.fromstring(bpmn_content.encode("utf-8")) + except etree.XMLSyntaxError as exc: + issues.append(error("BPMN-001", f"Malformed XML: {exc}", file=filename)) + return issues # Can't continue + + # 2. Root element check + expected_tag = f"{{{BPMN_NS}}}definitions" + if tree.tag != expected_tag: + issues.append(error( + "BPMN-002", + f"Root element must be , got <{tree.tag}>", + file=filename, + )) + + # 3. Forbidden vendor namespaces (check all unique namespace URIs in document) + seen_ns: set = set() + for elem in tree.iter(): + for uri in (elem.nsmap or {}).values(): + seen_ns.add(uri) + for uri, vendor in _FORBIDDEN_NAMESPACES.items(): + if uri in seen_ns: + issues.append(error( + "BPMN-003", + f"Forbidden {vendor} namespace detected: {uri}", + file=filename, + )) + + # 4. At least one process + processes = tree.findall(f"{{{BPMN_NS}}}process") + if not processes: + issues.append(error( + "BPMN-004", + "No element found", + file=filename, + )) + + # 5. Duplicate IDs + all_ids: dict = {} + for elem in tree.iter(): + eid = elem.get("id") + if eid: + if eid in all_ids: + issues.append(error( + "BPMN-005", + f"Duplicate id '{eid}' (first seen on <{all_ids[eid]}>)", + file=filename, + )) + else: + all_ids[eid] = elem.tag + + if not issues: + issues.append(info("BPMN-000", "BPMN XML structure is valid", file=filename)) + + return issues diff --git a/.github/skills/bpmn_author/validators/swimlane_validator.py b/.github/skills/bpmn_author/validators/swimlane_validator.py new file mode 100644 index 0000000000..e1affbad44 --- /dev/null +++ b/.github/skills/bpmn_author/validators/swimlane_validator.py @@ -0,0 +1,115 @@ +""" +BPMN swimlane validator. + +Validates DAK swimlane constraints: +- Lanes must be present in every process +- No orphan tasks (every flow node referenced by a lane) +- No duplicate lane IDs +- Lane IDs are valid FSH identifiers +""" + +import re +from typing import List +from lxml import etree + +from common.ig_errors import Issue, error, warning, info + +BPMN_NS = "http://www.omg.org/spec/BPMN/20100524/MODEL" + +# Valid FSH identifier pattern +_FSH_ID_RE = re.compile(r"^[A-Za-z0-9.\-]+$") + +# Flow node types that should be assigned to a lane +_FLOW_NODE_TAGS = { + f"{{{BPMN_NS}}}{tag}" + for tag in ( + "task", "userTask", "serviceTask", "sendTask", "receiveTask", + "manualTask", "businessRuleTask", "scriptTask", "callActivity", + "subProcess", "startEvent", "endEvent", "intermediateThrowEvent", + "intermediateCatchEvent", "boundaryEvent", + "exclusiveGateway", "parallelGateway", "inclusiveGateway", + "eventBasedGateway", "complexGateway", + ) +} + + +def validate_swimlanes(bpmn_content: str, *, filename: str = "unknown.bpmn") -> List[Issue]: + """Validate BPMN swimlane structure and return a list of issues. + + Checks: + 1. Every process has a laneSet + 2. Lane IDs are valid FSH identifiers + 3. No orphan flow nodes (every node referenced by a lane) + 4. No duplicate lane IDs across the document + """ + issues: List[Issue] = [] + + try: + tree = etree.fromstring(bpmn_content.encode("utf-8")) + except etree.XMLSyntaxError: + issues.append(error("SWIM-001", "Cannot parse XML", file=filename)) + return issues + + # Collect all lane IDs for duplicate check + seen_lane_ids: dict = {} + + processes = tree.findall(f"{{{BPMN_NS}}}process") + for proc in processes: + proc_id = proc.get("id", "?") + + # 1. laneSet present? + lane_sets = proc.findall(f"{{{BPMN_NS}}}laneSet") + if not lane_sets: + issues.append(error( + "SWIM-002", + f"Process '{proc_id}' has no ", + file=filename, + )) + continue + + # Collect all flowNodeRefs from lanes + all_refs: set = set() + for lane_set in lane_sets: + for lane in lane_set.iter(f"{{{BPMN_NS}}}lane"): + lane_id = lane.get("id", "") + + # 2. Valid FSH ID? + if lane_id and not _FSH_ID_RE.match(lane_id): + issues.append(warning( + "SWIM-003", + f"Lane id '{lane_id}' contains characters invalid for FSH identifiers " + f"(allowed: A-Z, a-z, 0-9, '-', '.')", + file=filename, + )) + + # Duplicate lane ID? + if lane_id: + if lane_id in seen_lane_ids: + issues.append(error( + "SWIM-004", + f"Duplicate lane id '{lane_id}'", + file=filename, + )) + else: + seen_lane_ids[lane_id] = True + + for ref in lane.findall(f"{{{BPMN_NS}}}flowNodeRef"): + if ref.text: + all_refs.add(ref.text.strip()) + + # 3. Orphan flow nodes? + for child in proc: + if child.tag in _FLOW_NODE_TAGS: + node_id = child.get("id", "") + if node_id and node_id not in all_refs: + issues.append(warning( + "SWIM-005", + f"Flow node '{node_id}' ({child.tag.split('}')[-1]}) " + f"is not referenced by any lane in process '{proc_id}'", + file=filename, + )) + + if not issues: + issues.append(info("SWIM-000", "Swimlane structure is valid", file=filename)) + + return issues diff --git a/.github/skills/bpmn_import/__init__.py b/.github/skills/bpmn_import/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/bpmn_import/actions/__init__.py b/.github/skills/bpmn_import/actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/bpmn_import/actions/bpmn_import_action.py b/.github/skills/bpmn_import/actions/bpmn_import_action.py new file mode 100644 index 0000000000..f1926060d0 --- /dev/null +++ b/.github/skills/bpmn_import/actions/bpmn_import_action.py @@ -0,0 +1,53 @@ +""" +BPMN Import action — wraps bpmn_extractor.py and validates lane→actor mapping. + +Environment variables: + DAK_LLM_API_KEY — LLM API key (optional; error interpretation skipped if absent) + DAK_LLM_MODEL — LLM model name (default: gpt-4o) + GITHUB_TOKEN — GitHub API token + DAK_IG_ROOT — IG root directory (default: current directory) +""" + +import glob +import os +import sys +from pathlib import Path + +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + +from common.ig_errors import format_issues, has_errors +from bpmn_import.validators.swimlane_actor_validator import validate_swimlane_actors + + +def main() -> None: + ig_root = os.environ.get("DAK_IG_ROOT", ".") + bpmn_files = glob.glob(os.path.join(ig_root, "input", "business-processes", "*.bpmn")) + + if not bpmn_files: + print("ℹ️ No BPMN files found in input/business-processes/") + sys.exit(0) + + all_issues = [] + for bpmn_path in bpmn_files: + print(f"📄 Validating: {bpmn_path}") + content = Path(bpmn_path).read_text(encoding="utf-8") + issues = validate_swimlane_actors( + content, + ig_root=ig_root, + filename=os.path.basename(bpmn_path), + ) + all_issues.extend(issues) + + print(format_issues(all_issues)) + + if has_errors(all_issues): + print("❌ BPMN import validation found errors.") + sys.exit(1) + + print("✅ All BPMN files passed lane→actor validation.") + + +if __name__ == "__main__": + main() diff --git a/.github/skills/bpmn_import/prompts/interpret_import_errors.md b/.github/skills/bpmn_import/prompts/interpret_import_errors.md new file mode 100644 index 0000000000..05089fa480 --- /dev/null +++ b/.github/skills/bpmn_import/prompts/interpret_import_errors.md @@ -0,0 +1,28 @@ +# Interpret Import Errors + +You are helping a DAK author understand errors from the BPMN import pipeline. + +## Import Output + +{import_output} + +## Errors Found + +{error_list} + +## Instructions + +For each error: +1. Explain what went wrong in plain language. +2. Identify the likely cause (missing actor, malformed BPMN, XSLT issue). +3. Suggest a concrete fix the author can apply. + +Return your analysis as JSON: +```json +{{ + "summary": "...", + "errors": [ + {{"code": "...", "message": "...", "cause": "...", "fix": "..."}} + ] +}} +``` diff --git a/.github/skills/bpmn_import/skills.yaml b/.github/skills/bpmn_import/skills.yaml new file mode 100644 index 0000000000..9ab46f485e --- /dev/null +++ b/.github/skills/bpmn_import/skills.yaml @@ -0,0 +1,18 @@ +# bpmn_import skill +name: bpmn_import +version: "0.1.0" +description: Import BPMN files via bpmn_extractor.py and validate lane-to-actor mapping + +commands: + - name: import-bpmn + description: Run bpmn_extractor.py pipeline and validate output + requires_llm: false + - name: validate-actors + description: Validate that every innermost lane maps to an ActorDefinition + requires_llm: false + +validators: + - swimlane_actor_validator + +prompts: + - interpret_import_errors diff --git a/.github/skills/bpmn_import/validators/__init__.py b/.github/skills/bpmn_import/validators/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/bpmn_import/validators/swimlane_actor_validator.py b/.github/skills/bpmn_import/validators/swimlane_actor_validator.py new file mode 100644 index 0000000000..28d10b668a --- /dev/null +++ b/.github/skills/bpmn_import/validators/swimlane_actor_validator.py @@ -0,0 +1,73 @@ +""" +Swimlane → ActorDefinition validator. + +Checks that every innermost ```` in BPMN files has a +corresponding ``input/fsh/actors/ActorDefinition-DAK.X.fsh`` file. +""" + +import os +from typing import List +from lxml import etree + +from common.ig_errors import Issue, error, warning, info +from common.fsh_utils import instance_exists + +BPMN_NS = "http://www.omg.org/spec/BPMN/20100524/MODEL" + + +def _is_innermost_lane(lane: etree._Element) -> bool: + """Return True if the lane has no nested childLaneSet.""" + return lane.find(f"{{{BPMN_NS}}}childLaneSet") is None + + +def validate_swimlane_actors( + bpmn_content: str, + *, + ig_root: str = ".", + filename: str = "unknown.bpmn", +) -> List[Issue]: + """Validate that every innermost lane has a matching ActorDefinition FSH file. + + Args: + bpmn_content: BPMN XML as a string. + ig_root: Path to the IG root directory. + filename: Source filename for issue reporting. + + Returns: + List of validation issues. + """ + issues: List[Issue] = [] + + try: + tree = etree.fromstring(bpmn_content.encode("utf-8")) + except etree.XMLSyntaxError as exc: + issues.append(error("ACTOR-001", f"Cannot parse BPMN XML: {exc}", file=filename)) + return issues + + for lane in tree.iter(f"{{{BPMN_NS}}}lane"): + if not _is_innermost_lane(lane): + continue + + lane_id = lane.get("id", "") + lane_name = lane.get("name", lane_id) + + if not lane_id: + issues.append(warning( + "ACTOR-002", + f"Lane without id attribute (name='{lane_name}')", + file=filename, + )) + continue + + if not instance_exists(ig_root, lane_id): + issues.append(error( + "ACTOR-003", + f"No ActorDefinition FSH file for lane '{lane_id}' " + f"(expected: input/fsh/actors/ActorDefinition-DAK.{lane_id}.fsh)", + file=filename, + )) + + if not issues: + issues.append(info("ACTOR-000", "All lanes map to ActorDefinition files", file=filename)) + + return issues diff --git a/.github/skills/cli/dak_skill.py b/.github/skills/cli/dak_skill.py new file mode 100644 index 0000000000..2ab4fd4f86 --- /dev/null +++ b/.github/skills/cli/dak_skill.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +dak-skill CLI — entry point for the DAK Skill Library. + +Usage: + dak-skill validate # DAK structural validation (no LLM needed) + dak-skill validate-ig # Full IG Publisher validation + dak-skill build-ig # Full IG Publisher build + dak-skill import-bpmn # Import BPMN files and validate + dak-skill author "..." # LLM-assisted BPMN authoring + dak-skill classify # Classify current issue + dak-skill --help # Show help + +Environment variables: + DAK_LLM_API_KEY — LLM API key (optional; LLM steps skipped if absent) + DAK_LLM_MODEL — LLM model name (default: gpt-4o) + DAK_IG_ROOT — IG root directory (default: current directory) +""" + +import argparse +import importlib +import sys +from pathlib import Path + +# Ensure the skills root is on sys.path +_SKILLS_ROOT = Path(__file__).resolve().parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + +# Map CLI commands to action module paths +_COMMANDS = { + "validate": "ig_publisher.actions.validate_dak_action", + "validate-ig": "ig_publisher.actions.validate_ig_action", + "build-ig": "ig_publisher.actions.build_ig_action", + "import-bpmn": "bpmn_import.actions.bpmn_import_action", + "author": "bpmn_author.actions.bpmn_author_action", + "classify": "dak_authoring.actions.classify_issue_action", + "interpret-errors": "ig_publisher.actions.interpret_errors_action", +} + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="dak-skill", + description="DAK Skill Library CLI", + ) + parser.add_argument( + "command", + choices=list(_COMMANDS.keys()), + help="Skill command to run", + ) + parser.add_argument( + "args", + nargs="*", + help="Additional arguments passed to the skill", + ) + + args = parser.parse_args() + + module_path = _COMMANDS[args.command] + try: + module = importlib.import_module(module_path) + except ImportError as exc: + print(f"❌ Failed to import skill module '{module_path}': {exc}", file=sys.stderr) + sys.exit(1) + + if hasattr(module, "main"): + module.main() + else: + print(f"❌ Module '{module_path}' has no main() function", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/common/__init__.py b/.github/skills/common/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/common/fsh_utils.py b/.github/skills/common/fsh_utils.py new file mode 100644 index 0000000000..813f17f423 --- /dev/null +++ b/.github/skills/common/fsh_utils.py @@ -0,0 +1,42 @@ +""" +FSH (FHIR Shorthand) utility helpers for DAK skill actions. + +Provides helpers for reading, validating, and generating small FSH snippets +used by BPMN import and DAK authoring skills. +""" + +import re +from pathlib import Path +from typing import Optional + + +def fsh_id_safe(raw_id: str) -> str: + """Sanitize a string to a valid FSH instance identifier. + + FSH identifiers allow ``[A-Za-z0-9\\-\\.]``. Characters outside that + set are replaced with ``-``. + """ + return re.sub(r"[^A-Za-z0-9.\-]", "-", raw_id) + + +def actor_fsh_path(ig_root: str, bare_id: str) -> Path: + """Return the expected path for an ActorDefinition FSH file. + + Convention from ``bpmn2fhirfsh.xsl``: + ``input/fsh/actors/ActorDefinition-DAK..fsh`` + """ + return Path(ig_root) / "input" / "fsh" / "actors" / f"ActorDefinition-DAK.{bare_id}.fsh" + + +def instance_exists(ig_root: str, bare_id: str) -> bool: + """Check whether an ActorDefinition FSH file exists for *bare_id*.""" + return actor_fsh_path(ig_root, bare_id).is_file() + + +def read_fsh_instance_id(fsh_path: Path) -> Optional[str]: + """Extract the ``Instance:`` identifier from a FSH file, if present.""" + if not fsh_path.is_file(): + return None + text = fsh_path.read_text(encoding="utf-8") + match = re.search(r"^Instance:\s*(\S+)", text, re.MULTILINE) + return match.group(1) if match else None diff --git a/.github/skills/common/ig_errors.py b/.github/skills/common/ig_errors.py new file mode 100644 index 0000000000..6da8fa658b --- /dev/null +++ b/.github/skills/common/ig_errors.py @@ -0,0 +1,76 @@ +""" +IG Publisher error-level constants and formatting helpers. + +All DAK skill validators report findings using these severity levels, +matching the IG Publisher output format. + +Usage: + from common.ig_errors import error, warning, info, fatal, format_issues + + issues = [] + issues.append(error("BPMN-001", "Zeebe namespace detected", file="test.bpmn")) + issues.append(warning("SWIM-002", "Lane has no tasks")) + print(format_issues(issues)) +""" + +from dataclasses import dataclass, field +from typing import List, Optional + + +# Severity constants (match IG Publisher levels) +FATAL = "FATAL" +ERROR = "ERROR" +WARNING = "WARNING" +INFORMATION = "INFORMATION" + + +@dataclass +class Issue: + """A single validation finding.""" + + severity: str + code: str + message: str + file: Optional[str] = None + line: Optional[int] = None + + def __str__(self) -> str: + location = "" + if self.file: + location = f" ({self.file}" + if self.line: + location += f":{self.line}" + location += ")" + return f"[{self.severity}] {self.code}: {self.message}{location}" + + +def fatal(code: str, message: str, **kwargs) -> Issue: + """Create a FATAL issue.""" + return Issue(severity=FATAL, code=code, message=message, **kwargs) + + +def error(code: str, message: str, **kwargs) -> Issue: + """Create an ERROR issue.""" + return Issue(severity=ERROR, code=code, message=message, **kwargs) + + +def warning(code: str, message: str, **kwargs) -> Issue: + """Create a WARNING issue.""" + return Issue(severity=WARNING, code=code, message=message, **kwargs) + + +def info(code: str, message: str, **kwargs) -> Issue: + """Create an INFORMATION issue.""" + return Issue(severity=INFORMATION, code=code, message=message, **kwargs) + + +def format_issues(issues: List[Issue]) -> str: + """Format a list of issues as a multi-line string.""" + if not issues: + return "✅ No issues found." + return "\n".join(str(i) for i in issues) + + +def has_errors(issues: List[Issue]) -> bool: + """Return True if any issue is FATAL or ERROR.""" + return any(i.severity in (FATAL, ERROR) for i in issues) diff --git a/.github/skills/common/ig_publisher_iface.py b/.github/skills/common/ig_publisher_iface.py new file mode 100644 index 0000000000..aeede8744d --- /dev/null +++ b/.github/skills/common/ig_publisher_iface.py @@ -0,0 +1,58 @@ +""" +IG Publisher interface — thin wrapper for invoking the FHIR IG Publisher. + +Uses the existing ``input/scripts/run_ig_publisher.py`` when available, or +falls back to running the publisher JAR directly. +""" + +import logging +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +def run_ig_publisher( + ig_root: Optional[str] = None, + *, + tx_server: Optional[str] = None, + extra_args: Optional[list] = None, +) -> subprocess.CompletedProcess: + """Run the FHIR IG Publisher. + + Args: + ig_root: Path to the IG root directory. Defaults to ``DAK_IG_ROOT`` + env var or current working directory. + tx_server: Terminology server URL. Pass ``"n/a"`` for offline builds. + extra_args: Additional CLI arguments for the publisher. + + Returns: + CompletedProcess with return code, stdout, and stderr. + """ + ig_root = ig_root or os.environ.get("DAK_IG_ROOT", ".") + ig_root_path = Path(ig_root) + + # Prefer the repo's own runner script if present + runner_script = ig_root_path / "input" / "scripts" / "run_ig_publisher.py" + use_python_runner = runner_script.is_file() + if use_python_runner: + cmd = [sys.executable, str(runner_script)] + else: + jar = os.environ.get( + "PUBLISHER_JAR", + str(ig_root_path / "input-cache" / "publisher.jar"), + ) + cmd = ["java", "-jar", jar, "-ig", str(ig_root_path)] + + if tx_server: + # run_ig_publisher.py uses argparse (--tx); the Java JAR uses -tx + tx_flag = "--tx" if use_python_runner else "-tx" + cmd.extend([tx_flag, tx_server]) + if extra_args: + cmd.extend(extra_args) + + logger.info("Running IG Publisher: %s", " ".join(cmd)) + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(ig_root_path)) diff --git a/.github/skills/common/llm_utils.py b/.github/skills/common/llm_utils.py new file mode 100644 index 0000000000..4f28b37ad7 --- /dev/null +++ b/.github/skills/common/llm_utils.py @@ -0,0 +1,134 @@ +""" +DAK LLM utilities — thin helpers around LiteLLM. + +LiteLLM (https://github.com/BerriAI/litellm, MIT License) is the trusted +external library that provides multi-provider LLM support (OpenAI, Anthropic, +Google, etc.) via a single ``completion()`` call. This module adds only +DAK-specific environment-variable bridging and a JSON-extraction helper. + +No custom LLM facade to maintain — callers use ``litellm.completion()`` +directly via the convenience wrapper below. + +Usage: + from common.llm_utils import dak_completion, parse_json_response + + text = dak_completion("Explain BPMN swimlanes") + data = dak_completion(prompt, structured_output=True) +""" + +import json +import logging +import os +from typing import Any, Dict, List, Optional, Union + +logger = logging.getLogger(__name__) + + +def get_llm_config() -> tuple: + """Return ``(api_key, model)`` from DAK environment variables. + + Reads: + ``DAK_LLM_API_KEY`` — LLM provider API key (repo secret or ``.env``). + ``DAK_LLM_MODEL`` — model identifier (default ``gpt-4o``). + """ + api_key = os.environ.get("DAK_LLM_API_KEY", "") + model = os.environ.get("DAK_LLM_MODEL", "gpt-4o") + return api_key, model + + +def is_llm_available() -> bool: + """Return True if a DAK LLM API key is configured.""" + return bool(os.environ.get("DAK_LLM_API_KEY", "")) + + +def dak_completion( + prompt: str, + *, + system_prompt: str = "", + structured_output: bool = False, + temperature: float = 0.2, + api_key: Optional[str] = None, + model: Optional[str] = None, +) -> Union[str, Dict[str, Any]]: + """Call an LLM via LiteLLM with DAK environment defaults. + + This is a thin convenience wrapper around ``litellm.completion()`` + that reads ``DAK_LLM_API_KEY`` / ``DAK_LLM_MODEL`` from the + environment so callers don't repeat the boilerplate. + + Args: + prompt: User prompt text. + system_prompt: Optional system-level instruction. + structured_output: When True, parse the response as JSON. + temperature: Sampling temperature. + api_key: Override for ``DAK_LLM_API_KEY``. + model: Override for ``DAK_LLM_MODEL``. + + Returns: + ``str`` (plain text) or ``dict`` (when *structured_output* is True). + + Raises: + RuntimeError: If no API key is available. + ImportError: If ``litellm`` is not installed. + """ + _api_key = api_key or os.environ.get("DAK_LLM_API_KEY", "") + _model = model or os.environ.get("DAK_LLM_MODEL", "gpt-4o") + + if not _api_key: + raise RuntimeError( + "DAK_LLM_API_KEY not set — cannot call LLM. " + "Set the key in a repo secret or local .env file." + ) + + try: + import litellm + except ImportError as exc: + raise ImportError( + "litellm is required for LLM features. " + "Install it with: pip install 'litellm>=1.0.0'" + ) from exc + + messages: List[Dict[str, str]] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + logger.info("LLM request: model=%s tokens≈%d", _model, len(prompt) // 4) + + response = litellm.completion( + model=_model, + messages=messages, + temperature=temperature, + api_key=_api_key, + ) + + text: str = response.choices[0].message.content.strip() + + if structured_output: + return parse_json_response(text) + return text + + +def parse_json_response(text: str) -> Dict[str, Any]: + """Best-effort JSON extraction from LLM response text. + + Handles bare JSON, ``json`` code-fenced blocks, and generic fences. + Falls back to ``{"raw": text}`` when parsing fails. + """ + # Try direct parse first + try: + return json.loads(text) + except json.JSONDecodeError: + pass + # Try extracting from markdown code fence + for fence in ("```json", "```"): + if fence in text: + start = text.index(fence) + len(fence) + end = text.index("```", start) + try: + return json.loads(text[start:end].strip()) + except (json.JSONDecodeError, ValueError): + pass + # Last resort: return as dict with raw text + return {"raw": text} + diff --git a/.github/skills/common/prompt_loader.py b/.github/skills/common/prompt_loader.py new file mode 100644 index 0000000000..8da3b23111 --- /dev/null +++ b/.github/skills/common/prompt_loader.py @@ -0,0 +1,65 @@ +""" +Prompt loader for DAK skill actions. + +Prompts are stored as Markdown files with ``{variable}`` placeholders. +``load_prompt()`` reads the file and substitutes variables using +``str.format_map``. + +Usage: + from common.prompt_loader import load_prompt + + prompt = load_prompt("bpmn_author", "create_or_edit_bpmn", + bpmn_xml="", + user_request="Add a pharmacy lane") +""" + +import os +from pathlib import Path +from typing import Any + + +# Root of the skills directory (parent of common/) +_SKILLS_ROOT = Path(__file__).resolve().parent.parent + + +def load_prompt(skill_name: str, prompt_name: str, **variables: Any) -> str: + """Load a ``.md`` prompt template and fill ``{variable}`` placeholders. + + The file is resolved as:: + + .github/skills//prompts/.md + + Falls back to:: + + .github/skills/common/prompts/.md + + Args: + skill_name: Skill directory name (e.g. ``"bpmn_author"``). + prompt_name: Prompt file stem (without ``.md``). + **variables: Substitution values for ``{key}`` placeholders. + + Returns: + The rendered prompt string. + + Raises: + FileNotFoundError: If neither skill-specific nor common prompt exists. + """ + skill_path = _SKILLS_ROOT / skill_name / "prompts" / f"{prompt_name}.md" + common_path = _SKILLS_ROOT / "common" / "prompts" / f"{prompt_name}.md" + + for path in (skill_path, common_path): + if path.is_file(): + template = path.read_text(encoding="utf-8") + return template.format_map(_SafeDict(variables)) + + raise FileNotFoundError( + f"Prompt '{prompt_name}.md' not found in " + f"'{skill_path}' or '{common_path}'" + ) + + +class _SafeDict(dict): + """dict subclass that returns ``{key}`` for missing keys instead of raising KeyError.""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" diff --git a/.github/skills/common/prompts/actor_context.md b/.github/skills/common/prompts/actor_context.md new file mode 100644 index 0000000000..e0af43766b --- /dev/null +++ b/.github/skills/common/prompts/actor_context.md @@ -0,0 +1,25 @@ +# Actor Context + +When working with BPMN lanes and DAK personas, the following mapping applies: + +## Lane ID → ActorDefinition Mapping + +``` +BPMN: +FSH: Instance: X + InstanceOf: $SGActor + Title: "Some Name" +``` + +- The lane `@id` is the bare FSH instance identifier — **no `DAK.` prefix** on the lane itself. +- `bpmn2fhirfsh.xsl` generates the file as `ActorDefinition-DAK.{@id}.fsh` + and sets `* id = "DAK.{@id}"` inside the FSH. +- The lane `@id` in BPMN = the bare instance name. + +## Existing Actors + +{actor_list} + +## Valid Lane ID Characters + +Lane IDs must match `[A-Za-z0-9\-\.]` to be valid FSH instance identifiers. diff --git a/.github/skills/common/prompts/bpmn_xml_schema.md b/.github/skills/common/prompts/bpmn_xml_schema.md new file mode 100644 index 0000000000..52b8576ea1 --- /dev/null +++ b/.github/skills/common/prompts/bpmn_xml_schema.md @@ -0,0 +1,57 @@ +# BPMN 2.0 XML Schema Reference + +Standard BPMN 2.0 XML structure for DAK workflows: + +```xml + + + + + + + + + + + Task_1 + + + Task_2 + + + + + + + + + + + + + + +``` + +## Key elements + +| Element | Purpose | +|---|---| +| `` | Top-level container for participants (pools) | +| `` | A pool — references a `` | +| `` | Contains lanes, tasks, events, gateways, flows | +| `` | Container for lanes within a process | +| `` | Swimlane — innermost lanes = DAK personas | +| `` | Generic task | +| `` | Human-performed task | +| `` | System-performed task | +| `` | XOR decision point | +| `` | AND fork/join | +| `` | Process entry point | +| `` | Process termination point | +| `` | Directed edge between flow nodes | diff --git a/.github/skills/common/prompts/dak_bpmn_constraints.md b/.github/skills/common/prompts/dak_bpmn_constraints.md new file mode 100644 index 0000000000..ed8f007eb5 --- /dev/null +++ b/.github/skills/common/prompts/dak_bpmn_constraints.md @@ -0,0 +1,19 @@ +# DAK BPMN Constraints + +When authoring or editing BPMN for a WHO Digital Adaptation Kit (DAK): + +1. **Standard BPMN 2.0 only** — no Zeebe, Camunda, or other vendor extensions. + The root element must be ``. + +2. **Swimlane rules:** + - Every process must have at least one `` with at least one ``. + - Innermost lanes (lanes with no ``) represent DAK **personas** and map to FHIR `ActorDefinition` instances. + - `` — the `id` attribute is the **bare FSH instance identifier**. It must match `[A-Za-z0-9\-\.]`. + - `` — the `name` attribute becomes the human-readable `Title:` in the generated FSH. + - Every ``, ``, ``, etc., must be referenced by exactly one lane via ``. + +3. **No orphan tasks** — every flow node inside a process must appear in a lane's `` list. + +4. **No duplicate IDs** — all `id` attributes across the BPMN document must be unique. + +5. **File location:** BPMN files are stored in `input/business-processes/*.bpmn`. diff --git a/.github/skills/dak_authoring/__init__.py b/.github/skills/dak_authoring/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/dak_authoring/actions/__init__.py b/.github/skills/dak_authoring/actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/dak_authoring/actions/classify_issue_action.py b/.github/skills/dak_authoring/actions/classify_issue_action.py new file mode 100644 index 0000000000..2191a88fe4 --- /dev/null +++ b/.github/skills/dak_authoring/actions/classify_issue_action.py @@ -0,0 +1,181 @@ +""" +Issue classifier — applies content:L1/L2/L3/translation labels. +Uses LLM when DAK_LLM_API_KEY is set; falls back to keyword matching. +Both paths use the same label application logic. +""" + +import os +import re +import sys +from pathlib import Path + +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + +# ── Keyword lists ────────────────────────────────────────────────────────── + +L1_KEYWORDS = [ + # WHO guideline source + "recommendation", "who recommendation", "guideline", "who guideline", + "clinical guideline", "evidence", "evidence base", "evidence-based", + "narrative", "who narrative", "source content", "who document", + # Sections of WHO guideline documents + "section 2", "section 3", "section 4", "annex", "appendix", + "executive summary", "background", "scope", "target population", + # Clinical content + "clinical", "intervention", "outcome", "efficacy", "safety", + "contraindication", "dosage", "dose", "regimen", "protocol", + "screening", "diagnosis", "treatment", "management", "referral", + "counselling", "counseling", "antenatal", "postnatal", "maternal", + "newborn", "child", "adolescent", "immunization", "vaccination", + # Process + "new recommendation", "update recommendation", "change guideline", + "outdated", "superseded", "retracted", +] + +L2_KEYWORDS = [ + # BPMN / process + "bpmn", "business process", "swimlane", "swim lane", "workflow", + "process diagram", "process model", "process flow", "flow diagram", + "lane", "pool", "gateway", "sequence flow", "start event", "end event", + "user task", "service task", "business rule", "send task", "receive task", + # Personas / actors + "persona", "actor", "actordefinition", "actor definition", + "health worker", "healthcare worker", "community health worker", "chw", + "clinician", "nurse", "midwife", "physician", "doctor", "pharmacist", + "supervisor", "facility", "patient", "client", "caregiver", + # FHIR resources / FSH + "fhir", "fsh", "sushi", "profile", "instance", "extension", + "codesystem", "code system", "valueset", "value set", "conceptmap", + "structuredefinition", "logical model", "implementation guide", "ig", + # DAK components + "questionnaire", "data element", "data dictionary", "decision table", + "decision logic", "cql", "clinical quality language", "library", + "plandefinition", "activitydefinition", "measure", + "requirement", "non-functional", "functional requirement", + # DAK L2 editorial + "dak", "digital adaptation kit", "l2", "component 2", "component 3", + "component 4", "component 5", "component 6", "component 7", "component 8", + "business process", "generic persona", "related persona", + "core data element", "decision support", "scheduling logic", + "indicator", "performance indicator", +] + +L3_KEYWORDS = [ + # Geographic / organizational scope + "national", "country", "country-specific", "country adaptation", + "local", "regional", "district", "sub-national", + "program", "programme", "program-level", "programme-level", + # Adaptation process + "adaptation", "adapt", "localize", "localise", "contextualize", + "contextualise", "customise", "customize", "context-specific", + "l3", "layer 3", "implementation guide", "conformance", + # System / interoperability + "system", "ehr", "emr", "electronic health record", + "health information system", "his", "dhis2", "openemr", "openmrs", + "mapping", "terminology mapping", "code mapping", + "interoperability", "integration", "api", "openapi", + "capability statement", +] + +TRANSLATION_KEYWORDS = [ + # Languages + "translation", "translate", "translated", "translating", + "arabic", "\u0639\u0631\u0628\u064a", "ar", + "chinese", "mandarin", "\u4e2d\u6587", "zh", + "french", "fran\u00e7ais", "francais", "fr", + "russian", "\u0440\u0443\u0441\u0441\u043a\u0438\u0439", "ru", + "spanish", "espa\u00f1ol", "espanol", "es", + "portuguese", "portugu\u00eas", "pt", + # Translation tooling + "weblate", "po file", ".po", "pot file", ".pot", "gettext", + "msgstr", "msgid", "locale", "localization", "localisation", + "i18n", "l10n", "internationalization", + # Translation issues + "mistranslation", "mistranslated", "wrong translation", + "translation error", "translation review", "translation update", + "translatable string", "untranslated", "missing translation", +] + + +def _keyword_in_text(keyword: str, text: str) -> bool: + """Check if ``keyword`` appears in ``text``. + + Keywords of 3 characters or fewer are matched as whole words only + (word-boundary check) to avoid false positives from short language + codes like ``"ar"`` matching inside ``"pharmacist"``. + """ + if len(keyword) <= 3: + return bool(re.search(r'\b' + re.escape(keyword) + r'\b', text)) + return keyword in text + + +def classify_by_keywords(title: str, body: str) -> list: + """Keyword-based fallback classifier. Case-insensitive. No LLM needed.""" + text = (title + " " + (body or "")).lower() + labels = [] + if any(_keyword_in_text(k, text) for k in L1_KEYWORDS): + labels.append("content:L1") + if any(_keyword_in_text(k, text) for k in L2_KEYWORDS): + labels.append("content:L2") + if any(_keyword_in_text(k, text) for k in L3_KEYWORDS): + labels.append("content:L3") + if any(_keyword_in_text(k, text) for k in TRANSLATION_KEYWORDS): + labels.append("content:translation") + return labels + + +def apply_labels(issue_number: int, labels: list) -> None: + """Apply labels to issue via GitHub REST API using GITHUB_TOKEN.""" + import requests + + token = os.environ["GITHUB_TOKEN"] + repo = os.environ["GITHUB_REPOSITORY"] + if not labels: + return + r = requests.post( + f"https://api.github.com/repos/{repo}/issues/{issue_number}/labels", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + json={"labels": labels}, + timeout=10, + ) + r.raise_for_status() + print(f"\u2705 Applied labels: {labels}") + + +def main() -> None: + from common.prompt_loader import load_prompt + from common.llm_utils import dak_completion + + issue_number = int(os.environ["ISSUE_NUMBER"]) + title = os.environ.get("ISSUE_TITLE", "") + body = os.environ.get("ISSUE_BODY", "") + api_key = os.environ.get("DAK_LLM_API_KEY", "") + + if api_key: + # LLM path + prompt = load_prompt( + "dak_authoring", "classify_issue", + issue_title=title, issue_body=body[:4000], + ) + result = dak_completion( + prompt, structured_output=True, + api_key=api_key, + model=os.environ.get("DAK_LLM_MODEL", "gpt-4o-mini"), + ) + labels = result.get("labels", []) + print(f"LLM classification: {result.get('reasoning')}") + else: + # Keyword fallback — no LLM cost + labels = classify_by_keywords(title, body) + print(f"\u26a0\ufe0f No LLM key \u2014 keyword fallback used. Labels: {labels}") + + apply_labels(issue_number, labels) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/dak_authoring/actions/dak_authoring_action.py b/.github/skills/dak_authoring/actions/dak_authoring_action.py new file mode 100644 index 0000000000..8105a27e47 --- /dev/null +++ b/.github/skills/dak_authoring/actions/dak_authoring_action.py @@ -0,0 +1,51 @@ +""" +DAK L2 authoring action — processes content:L2 labeled issues. + +Environment variables: + DAK_LLM_API_KEY — LLM API key (optional; LLM steps skipped if absent) + DAK_LLM_MODEL — LLM model name (default: gpt-4o) + GITHUB_TOKEN — GitHub API token + ISSUE_NUMBER — GitHub issue number + ISSUE_TITLE — Issue title + ISSUE_BODY — Issue body text +""" + +import os +import sys +from pathlib import Path + +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + + +def main() -> None: + api_key = os.environ.get("DAK_LLM_API_KEY", "") + if not api_key: + print("⚠️ DAK_LLM_API_KEY not set — LLM step skipped (structural validation still runs)") + sys.exit(0) + + from common.llm_utils import dak_completion + from common.prompt_loader import load_prompt + + issue_title = os.environ.get("ISSUE_TITLE", "") + issue_body = os.environ.get("ISSUE_BODY", "") + model = os.environ.get("DAK_LLM_MODEL", "gpt-4o") + + prompt = load_prompt( + "dak_authoring", "l2_authoring", + issue_title=issue_title, + issue_body=issue_body[:4000], + ) + + print(f"🤖 Planning L2 content changes with {model}...") + result = dak_completion(prompt, structured_output=True, api_key=api_key, model=model) + + print(f"Summary: {result.get('summary', 'N/A')}") + for change in result.get("changes", []): + print(f" {change.get('action', '?')}: {change.get('file', '?')}") + print(f" {change.get('description', '')}") + + +if __name__ == "__main__": + main() diff --git a/.github/skills/dak_authoring/prompts/change_proposal.md b/.github/skills/dak_authoring/prompts/change_proposal.md new file mode 100644 index 0000000000..4b41fa43bb --- /dev/null +++ b/.github/skills/dak_authoring/prompts/change_proposal.md @@ -0,0 +1,22 @@ +# Change Proposal + +You are proposing a change to a WHO Digital Adaptation Kit (DAK). + +## Issue + +**Title:** {issue_title} +**Body:** {issue_body} + +## Proposed Changes + +{change_list} + +## Instructions + +Review the proposed changes and create a change proposal suitable for a pull request. +Include: +1. Summary of what changes and why +2. Impact analysis (what other components are affected) +3. Validation checklist + +Return as markdown suitable for a PR description. diff --git a/.github/skills/dak_authoring/prompts/classify_issue.md b/.github/skills/dak_authoring/prompts/classify_issue.md new file mode 100644 index 0000000000..5fe9067555 --- /dev/null +++ b/.github/skills/dak_authoring/prompts/classify_issue.md @@ -0,0 +1,29 @@ +# Classify Issue + +You are a classifier for WHO Digital Adaptation Kit (DAK) GitHub issues. + +## Issue + +**Title:** {issue_title} + +**Body:** +{issue_body} + +## Label Definitions + +- **content:L1** — WHO source guideline content: recommendations, evidence, narrative, clinical protocols +- **content:L2** — DAK FHIR assets: BPMN, actors, questionnaires, CQL, data elements, decision tables +- **content:L3** — Implementation adaptations: national/program-level customizations, system integration +- **content:translation** — Translation of any content layer into any language + +## Instructions + +Classify this issue. An issue may have multiple labels or none. + +Return JSON: +```json +{{ + "reasoning": "Brief explanation of classification decision", + "labels": ["content:L1", "content:L2"] +}} +``` diff --git a/.github/skills/dak_authoring/prompts/l2_authoring.md b/.github/skills/dak_authoring/prompts/l2_authoring.md new file mode 100644 index 0000000000..2dbec40b39 --- /dev/null +++ b/.github/skills/dak_authoring/prompts/l2_authoring.md @@ -0,0 +1,36 @@ +# L2 Authoring + +You are a DAK L2 content author for WHO Digital Adaptation Kits. + +## Issue + +**Title:** {issue_title} +**Body:** {issue_body} + +## Available Components + +- BPMN business processes (input/business-processes/*.bpmn) +- Actor definitions (input/fsh/actors/) +- Questionnaires (input/fsh/questionnaires/) +- Decision tables (input/cql/ and input/fsh/plandefinitions/) +- Data elements (input/fsh/models/) + +## Instructions + +Based on the issue, determine what L2 DAK content needs to be created or modified. +Provide a structured plan with specific file changes. + +Return JSON: +```json +{{ + "summary": "...", + "components_affected": ["bpmn", "actors", ...], + "changes": [ + {{ + "file": "input/business-processes/example.bpmn", + "action": "create|modify", + "description": "..." + }} + ] +}} +``` diff --git a/.github/skills/dak_authoring/skills.yaml b/.github/skills/dak_authoring/skills.yaml new file mode 100644 index 0000000000..ca760b431b --- /dev/null +++ b/.github/skills/dak_authoring/skills.yaml @@ -0,0 +1,17 @@ +# dak_authoring skill +name: dak_authoring +version: "0.1.0" +description: Issue classification and DAK L2 content authoring + +commands: + - name: classify-issue + description: Classify a GitHub issue and apply content labels + requires_llm: false # keyword fallback works without LLM + - name: author-l2 + description: Author L2 DAK content from an issue description + requires_llm: true + +prompts: + - classify_issue + - l2_authoring + - change_proposal diff --git a/.github/skills/docker-compose.yml b/.github/skills/docker-compose.yml new file mode 100644 index 0000000000..0613910b6c --- /dev/null +++ b/.github/skills/docker-compose.yml @@ -0,0 +1,27 @@ +# DAK Skills — local compose +# Alias: alias dak='docker compose -f .github/skills/docker-compose.yml run --rm' +# Usage: dak validate | dak validate-ig | dak import-bpmn | dak author "..." | dak shell + +x-dak: &dak + image: dak-skill:latest + build: + context: .github/skills + dockerfile: Dockerfile + volumes: + - .:/workspace + - dak-pkg:/var/lib/.fhir + - dak-igcache:/workspace/fhir-package-cache + env_file: [.env] + working_dir: /workspace + +services: + validate: { <<: *dak, command: [validate] } + validate-ig: { <<: *dak, command: [validate-ig] } + import-bpmn: { <<: *dak, command: [import-bpmn] } + build-ig: { <<: *dak, command: [build-ig] } + author: { <<: *dak, command: [author] } + shell: { <<: *dak, entrypoint: /bin/bash } + +volumes: + dak-pkg: + dak-igcache: diff --git a/.github/skills/ig_publisher/__init__.py b/.github/skills/ig_publisher/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/ig_publisher/actions/__init__.py b/.github/skills/ig_publisher/actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/ig_publisher/actions/build_ig_action.py b/.github/skills/ig_publisher/actions/build_ig_action.py new file mode 100644 index 0000000000..000731d307 --- /dev/null +++ b/.github/skills/ig_publisher/actions/build_ig_action.py @@ -0,0 +1,40 @@ +""" +IG Publisher build action — runs the full IG Publisher build. + +Environment variables: + GITHUB_TOKEN — GitHub API token + DAK_IG_ROOT — IG root directory (default: current directory) + DAK_TX_SERVER — Terminology server URL (optional) +""" + +import os +import sys +from pathlib import Path + +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + +from common.ig_publisher_iface import run_ig_publisher + + +def main() -> None: + ig_root = os.environ.get("DAK_IG_ROOT", ".") + tx_server = os.environ.get("DAK_TX_SERVER", "") + + print("🏗️ Running full IG Publisher build...") + result = run_ig_publisher(ig_root, tx_server=tx_server or None) + + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + + if result.returncode != 0: + print("❌ IG Publisher build failed.") + sys.exit(1) + + print("✅ IG Publisher build completed successfully.") + + +if __name__ == "__main__": + main() diff --git a/.github/skills/ig_publisher/actions/interpret_errors_action.py b/.github/skills/ig_publisher/actions/interpret_errors_action.py new file mode 100644 index 0000000000..348f09d88c --- /dev/null +++ b/.github/skills/ig_publisher/actions/interpret_errors_action.py @@ -0,0 +1,50 @@ +""" +IG Publisher error interpretation action — uses LLM to explain build errors. + +Environment variables: + DAK_LLM_API_KEY — LLM API key (skipped if absent) + DAK_LLM_MODEL — LLM model name (default: gpt-4o-mini) + GITHUB_TOKEN — GitHub API token + PR_NUMBER — PR number for posting results +""" + +import os +import sys +from pathlib import Path + +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + + +def main() -> None: + api_key = os.environ.get("DAK_LLM_API_KEY", "") + if not api_key: + print("⚠️ DAK_LLM_API_KEY not set — LLM step skipped (structural validation still runs)") + sys.exit(0) + + from common.llm_utils import dak_completion + from common.prompt_loader import load_prompt + + model = os.environ.get("DAK_LLM_MODEL", "gpt-4o-mini") + + # Read build output from previous step (passed via file or env) + build_output = os.environ.get("BUILD_OUTPUT", "No build output available.") + + prompt = load_prompt( + "ig_publisher", "interpret_ig_errors", + build_output=build_output[:8000], + error_summary="See build output above.", + ) + + print(f"🤖 Interpreting errors with {model}...") + result = dak_completion(prompt, structured_output=True, api_key=api_key, model=model) + + print(f"Summary: {result.get('summary', 'N/A')}") + for finding in result.get("findings", []): + print(f" [{finding.get('severity')}] {finding.get('message')}") + print(f" Fix: {finding.get('fix')}") + + +if __name__ == "__main__": + main() diff --git a/.github/skills/ig_publisher/actions/validate_dak_action.py b/.github/skills/ig_publisher/actions/validate_dak_action.py new file mode 100644 index 0000000000..7120060560 --- /dev/null +++ b/.github/skills/ig_publisher/actions/validate_dak_action.py @@ -0,0 +1,63 @@ +""" +DAK structural validation action — validates repository structure +without requiring IG Publisher or LLM. + +Environment variables: + GITHUB_TOKEN — GitHub API token + PR_NUMBER — PR number for posting results + DAK_IG_ROOT — IG root directory (default: current directory) +""" + +import glob +import os +import sys +from pathlib import Path + +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + +from common.ig_errors import Issue, error, warning, info, format_issues, has_errors +from bpmn_author.validators.bpmn_xml_validator import validate_bpmn_xml +from bpmn_author.validators.swimlane_validator import validate_swimlanes +from bpmn_import.validators.swimlane_actor_validator import validate_swimlane_actors + + +def validate_structure(ig_root: str) -> list: + """Run structural validation checks and return issues.""" + issues = [] + root = Path(ig_root) + + # Check required files + if not (root / "sushi-config.yaml").is_file(): + issues.append(warning("DAK-001", "sushi-config.yaml not found")) + + if not (root / "ig.ini").is_file(): + issues.append(warning("DAK-002", "ig.ini not found")) + + # Validate BPMN files + bpmn_files = glob.glob(str(root / "input" / "business-processes" / "*.bpmn")) + for bpmn_path in bpmn_files: + content = Path(bpmn_path).read_text(encoding="utf-8") + fname = os.path.basename(bpmn_path) + issues.extend(validate_bpmn_xml(content, filename=fname)) + issues.extend(validate_swimlanes(content, filename=fname)) + issues.extend(validate_swimlane_actors(content, ig_root=ig_root, filename=fname)) + + return issues + + +def main() -> None: + ig_root = os.environ.get("DAK_IG_ROOT", ".") + issues = validate_structure(ig_root) + print(format_issues(issues)) + + if has_errors(issues): + print("❌ DAK structural validation found errors.") + sys.exit(1) + + print("✅ DAK structural validation passed.") + + +if __name__ == "__main__": + main() diff --git a/.github/skills/ig_publisher/actions/validate_ig_action.py b/.github/skills/ig_publisher/actions/validate_ig_action.py new file mode 100644 index 0000000000..f23bf6a7dd --- /dev/null +++ b/.github/skills/ig_publisher/actions/validate_ig_action.py @@ -0,0 +1,40 @@ +""" +IG Publisher validation action — runs the FHIR IG Publisher in validation mode. + +Environment variables: + GITHUB_TOKEN — GitHub API token + DAK_IG_ROOT — IG root directory (default: current directory) + DAK_TX_SERVER — Terminology server URL (optional; default: n/a for offline) +""" + +import os +import sys +from pathlib import Path + +_SKILLS_ROOT = Path(__file__).resolve().parent.parent.parent +if str(_SKILLS_ROOT) not in sys.path: + sys.path.insert(0, str(_SKILLS_ROOT)) + +from common.ig_publisher_iface import run_ig_publisher + + +def main() -> None: + ig_root = os.environ.get("DAK_IG_ROOT", ".") + tx_server = os.environ.get("DAK_TX_SERVER", "n/a") + + print(f"🏗️ Running IG Publisher validation (tx={tx_server})...") + result = run_ig_publisher(ig_root, tx_server=tx_server) + + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + + if result.returncode != 0: + print("❌ IG Publisher validation failed.") + sys.exit(1) + + print("✅ IG Publisher validation passed.") + + +if __name__ == "__main__": + main() diff --git a/.github/skills/ig_publisher/prompts/interpret_ig_errors.md b/.github/skills/ig_publisher/prompts/interpret_ig_errors.md new file mode 100644 index 0000000000..0d05f1eecc --- /dev/null +++ b/.github/skills/ig_publisher/prompts/interpret_ig_errors.md @@ -0,0 +1,38 @@ +# Interpret IG Publisher Errors + +You are helping a FHIR Implementation Guide author understand build errors. + +## Build Output + +{build_output} + +## Error Summary + +{error_summary} + +## Instructions + +For each FATAL or ERROR finding: +1. Explain what went wrong in plain language. +2. Identify the likely cause (missing profile, invalid reference, FSH syntax, etc.). +3. Suggest a concrete fix. + +For WARNINGs, briefly note whether they need attention. + +Return your analysis as JSON: +```json +{{ + "summary": "...", + "fatal_count": 0, + "error_count": 0, + "warning_count": 0, + "findings": [ + {{ + "severity": "ERROR", + "message": "...", + "cause": "...", + "fix": "..." + }} + ] +}} +``` diff --git a/.github/skills/ig_publisher/prompts/validate_dak.md b/.github/skills/ig_publisher/prompts/validate_dak.md new file mode 100644 index 0000000000..b6ff6e02ca --- /dev/null +++ b/.github/skills/ig_publisher/prompts/validate_dak.md @@ -0,0 +1,34 @@ +# Validate DAK Structure + +Review the DAK repository structure for completeness and correctness. + +## Repository Root + +{ig_root} + +## Files Found + +{file_listing} + +## Validation Results + +{validation_results} + +## Instructions + +Check: +1. Required directories exist (input/fsh/, input/business-processes/, etc.) +2. sushi-config.yaml is present and valid +3. All referenced profiles and extensions exist +4. No broken cross-references between BPMN lanes and ActorDefinition files + +Return your analysis as JSON: +```json +{{ + "valid": true/false, + "summary": "...", + "issues": [ + {{"severity": "...", "message": "...", "fix": "..."}} + ] +}} +``` diff --git a/.github/skills/ig_publisher/skills.yaml b/.github/skills/ig_publisher/skills.yaml new file mode 100644 index 0000000000..83fd29dff1 --- /dev/null +++ b/.github/skills/ig_publisher/skills.yaml @@ -0,0 +1,22 @@ +# ig_publisher skill +name: ig_publisher +version: "0.1.0" +description: IG Publisher validation, build, and error interpretation + +commands: + - name: validate-dak + description: Run DAK structural validation (no IG Publisher needed) + requires_llm: false + - name: validate-ig + description: Run full IG Publisher validation + requires_llm: false + - name: build-ig + description: Run full IG Publisher build + requires_llm: false + - name: interpret-errors + description: Interpret IG Publisher errors using LLM + requires_llm: true + +prompts: + - interpret_ig_errors + - validate_dak diff --git a/.github/skills/l1_review/actions/__init__.py b/.github/skills/l1_review/actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/l1_review/actions/l1_review_action.py b/.github/skills/l1_review/actions/l1_review_action.py new file mode 100644 index 0000000000..c41d38c03b --- /dev/null +++ b/.github/skills/l1_review/actions/l1_review_action.py @@ -0,0 +1,22 @@ +""" +L1 review action — placeholder for WHO source guideline review skill. + +This skill will be implemented in a future version. +""" + +import os +import sys + + +def main() -> None: + api_key = os.environ.get("DAK_LLM_API_KEY", "") + if not api_key: + print("⚠️ DAK_LLM_API_KEY not set — LLM step skipped") + sys.exit(0) + + print("ℹ️ L1 review skill is not yet implemented (planned for v0.2)") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/l1_review/skills.yaml b/.github/skills/l1_review/skills.yaml new file mode 100644 index 0000000000..28c9187577 --- /dev/null +++ b/.github/skills/l1_review/skills.yaml @@ -0,0 +1,9 @@ +# l1_review skill (stub — v0.2) +name: l1_review +version: "0.1.0" +description: WHO L1 source guideline review (placeholder for future implementation) + +commands: + - name: review-l1 + description: Review L1 guideline content + requires_llm: true diff --git a/.github/skills/l3_review/actions/__init__.py b/.github/skills/l3_review/actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/l3_review/actions/l3_review_action.py b/.github/skills/l3_review/actions/l3_review_action.py new file mode 100644 index 0000000000..03c932d79a --- /dev/null +++ b/.github/skills/l3_review/actions/l3_review_action.py @@ -0,0 +1,22 @@ +""" +L3 review action — placeholder for implementation adaptation review skill. + +This skill will be implemented in a future version. +""" + +import os +import sys + + +def main() -> None: + api_key = os.environ.get("DAK_LLM_API_KEY", "") + if not api_key: + print("⚠️ DAK_LLM_API_KEY not set — LLM step skipped") + sys.exit(0) + + print("ℹ️ L3 review skill is not yet implemented (planned for v0.3)") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/l3_review/skills.yaml b/.github/skills/l3_review/skills.yaml new file mode 100644 index 0000000000..0e1fe3f344 --- /dev/null +++ b/.github/skills/l3_review/skills.yaml @@ -0,0 +1,9 @@ +# l3_review skill (stub — v0.3) +name: l3_review +version: "0.1.0" +description: Implementation adaptation review (placeholder for future implementation) + +commands: + - name: review-l3 + description: Review L3 implementation adaptations + requires_llm: true diff --git a/labels/content_L1.json b/.github/skills/labels/content_L1.json similarity index 100% rename from labels/content_L1.json rename to .github/skills/labels/content_L1.json diff --git a/.github/skills/labels/content_L2.json b/.github/skills/labels/content_L2.json new file mode 100644 index 0000000000..bf42bc00a9 --- /dev/null +++ b/.github/skills/labels/content_L2.json @@ -0,0 +1 @@ +{"name": "content:L2", "color": "e4e669", "description": "DAK FHIR assets: BPMN, actors, questionnaires, CQL, data elements"} \ No newline at end of file diff --git a/.github/skills/labels/content_L3.json b/.github/skills/labels/content_L3.json new file mode 100644 index 0000000000..496744ed5f --- /dev/null +++ b/.github/skills/labels/content_L3.json @@ -0,0 +1 @@ +{"name": "content:L3", "color": "d73a4a", "description": "Implementation adaptations: national/program-level customizations"} \ No newline at end of file diff --git a/.github/skills/labels/content_translation.json b/.github/skills/labels/content_translation.json new file mode 100644 index 0000000000..a1bb055a35 --- /dev/null +++ b/.github/skills/labels/content_translation.json @@ -0,0 +1 @@ +{"name": "content:translation", "color": "0e8a16", "description": "Translation of any content layer"} \ No newline at end of file diff --git a/.github/skills/skills_registry.yaml b/.github/skills/skills_registry.yaml new file mode 100644 index 0000000000..94ee4d153f --- /dev/null +++ b/.github/skills/skills_registry.yaml @@ -0,0 +1,47 @@ +# DAK Skill Library — Registry +# +# Lists all registered skills with their entry points and capabilities. +# Used by the CLI and GitHub Actions to discover available skills. + +skills: + - name: bpmn_author + version: "0.1.0" + description: Author and edit standard BPMN 2.0 XML for DAK business processes + commands: [create-bpmn, edit-bpmn, validate-bpmn] + requires_llm_for: [create-bpmn, edit-bpmn] + + - name: bpmn_import + version: "0.1.0" + description: Import BPMN files and validate lane-to-actor mapping + commands: [import-bpmn, validate-actors] + requires_llm_for: [] + + - name: ig_publisher + version: "0.1.0" + description: IG Publisher validation, build, and error interpretation + commands: [validate-dak, validate-ig, build-ig, interpret-errors] + requires_llm_for: [interpret-errors] + + - name: dak_authoring + version: "0.1.0" + description: Issue classification and DAK L2 content authoring + commands: [classify-issue, author-l2] + requires_llm_for: [author-l2] + + - name: l1_review + version: "0.1.0" + description: WHO L1 source guideline review (placeholder) + commands: [review-l1] + requires_llm_for: [review-l1] + + - name: l3_review + version: "0.1.0" + description: Implementation adaptation review (placeholder) + commands: [review-l3] + requires_llm_for: [review-l3] + + - name: translation + version: "0.1.0" + description: Translation management (placeholder) + commands: [manage-translation] + requires_llm_for: [] diff --git a/.github/skills/translation/actions/__init__.py b/.github/skills/translation/actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/skills/translation/actions/translation_action.py b/.github/skills/translation/actions/translation_action.py new file mode 100644 index 0000000000..8bd7388edd --- /dev/null +++ b/.github/skills/translation/actions/translation_action.py @@ -0,0 +1,17 @@ +""" +Translation action — placeholder for translation management skill. + +This skill will be implemented in a future version. +""" + +import os +import sys + + +def main() -> None: + print("ℹ️ Translation skill is not yet implemented (planned for v0.3)") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/translation/skills.yaml b/.github/skills/translation/skills.yaml new file mode 100644 index 0000000000..a366ee266a --- /dev/null +++ b/.github/skills/translation/skills.yaml @@ -0,0 +1,9 @@ +# translation skill (stub — v0.3) +name: translation +version: "0.1.0" +description: Translation management (placeholder for future implementation) + +commands: + - name: manage-translation + description: Manage translation for DAK content + requires_llm: false diff --git a/.github/workflows/classify-issue.yml b/.github/workflows/classify-issue.yml new file mode 100644 index 0000000000..78aafb5f14 --- /dev/null +++ b/.github/workflows/classify-issue.yml @@ -0,0 +1,38 @@ +name: Classify Issue + +on: + issues: + types: [opened, edited] + +jobs: + classify: + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Check DAK enabled + # dak.json in the repo root signals that DAK skill features are active. + # See .github/skills/README.md for configuration details. + id: dak + run: | + [ -f dak.json ] \ + && echo "enabled=true" >> $GITHUB_OUTPUT \ + || echo "enabled=false" >> $GITHUB_OUTPUT + + - name: Install Python dependencies + if: steps.dak.outputs.enabled == 'true' + run: pip install litellm requests + + - name: Classify and label + if: steps.dak.outputs.enabled == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DAK_LLM_API_KEY: ${{ secrets.DAK_LLM_API_KEY }} + DAK_LLM_MODEL: ${{ vars.DAK_LLM_MODEL || 'gpt-4o-mini' }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: python3 .github/skills/dak_authoring/actions/classify_issue_action.py diff --git a/.github/workflows/pr-validate-slash.yml b/.github/workflows/pr-validate-slash.yml new file mode 100644 index 0000000000..149b9ebdf8 --- /dev/null +++ b/.github/workflows/pr-validate-slash.yml @@ -0,0 +1,75 @@ +name: PR Slash-Command Validate + +# Lets any collaborator post /validate in a PR comment to manually trigger +# DAK structural validation for that PR's branch. +# +# Security: This workflow uses the dispatch pattern (like pr-deploy-slash.yml) +# to avoid running untrusted PR code in a privileged issue_comment context. + +on: + issue_comment: + types: [created] + +jobs: + dispatch: + # Only run on pull-request comments that start with /validate from collaborators + if: > + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/validate') && + contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + permissions: + issues: write + actions: write + pull-requests: read + + steps: + - name: Acknowledge the slash command with a reaction + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: context.payload.comment.id, content: 'eyes', + }); + + - name: Get the PR branch name + id: pr + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, repo: context.repo.repo, + pull_number: context.issue.number, + }); + core.setOutput('branch', pr.data.head.ref); + + - name: Trigger ghbuild.yml for the PR branch + uses: actions/github-script@v7 + # Pass the branch name via env to avoid script injection from untrusted data + env: + BRANCH_REF: ${{ steps.pr.outputs.branch }} + with: + script: | + const branchRef = process.env.BRANCH_REF; + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, repo: context.repo.repo, + workflow_id: 'ghbuild.yml', + ref: branchRef, + inputs: { do_dak: 'true' }, + }); + + - name: Post a confirmation comment + uses: actions/github-script@v7 + env: + BRANCH_REF: ${{ steps.pr.outputs.branch }} + with: + script: | + const branch = process.env.BRANCH_REF; + const runsUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/ghbuild.yml`; + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: context.issue.number, + body: `👁️ **Validation triggered** for branch \`${branch}\`.\n\nThe DAK validation build is queued — [watch progress](${runsUrl}).`, + }); + diff --git a/.github/workflows/skill-l1-review.yml b/.github/workflows/skill-l1-review.yml new file mode 100644 index 0000000000..3b7e1f3270 --- /dev/null +++ b/.github/workflows/skill-l1-review.yml @@ -0,0 +1,29 @@ +name: L1 Guideline Review Skill + +on: + issues: + types: [labeled] + +jobs: + l1-review: + if: github.event.label.name == 'content:L1' + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Install Python dependencies + run: pip install litellm requests + + - name: Run L1 review skill + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DAK_LLM_API_KEY: ${{ secrets.DAK_LLM_API_KEY }} + DAK_LLM_MODEL: ${{ vars.DAK_LLM_MODEL || 'gpt-4o' }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: python3 .github/skills/l1_review/actions/l1_review_action.py diff --git a/.github/workflows/skill-l2-dak.yml b/.github/workflows/skill-l2-dak.yml new file mode 100644 index 0000000000..a783b95ed4 --- /dev/null +++ b/.github/workflows/skill-l2-dak.yml @@ -0,0 +1,30 @@ +name: L2 DAK Content Skill + +on: + issues: + types: [labeled] + +jobs: + dak-authoring: + if: github.event.label.name == 'content:L2' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Install Python dependencies + run: pip install litellm requests + + - name: Run L2 DAK authoring skill + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DAK_LLM_API_KEY: ${{ secrets.DAK_LLM_API_KEY }} + DAK_LLM_MODEL: ${{ vars.DAK_LLM_MODEL || 'gpt-4o' }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: python3 .github/skills/dak_authoring/actions/dak_authoring_action.py diff --git a/.github/workflows/skill-l3-review.yml b/.github/workflows/skill-l3-review.yml new file mode 100644 index 0000000000..ea57cab3a5 --- /dev/null +++ b/.github/workflows/skill-l3-review.yml @@ -0,0 +1,29 @@ +name: L3 Implementation Review Skill + +on: + issues: + types: [labeled] + +jobs: + l3-review: + if: github.event.label.name == 'content:L3' + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Install Python dependencies + run: pip install litellm requests + + - name: Run L3 review skill + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DAK_LLM_API_KEY: ${{ secrets.DAK_LLM_API_KEY }} + DAK_LLM_MODEL: ${{ vars.DAK_LLM_MODEL || 'gpt-4o' }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: python3 .github/skills/l3_review/actions/l3_review_action.py diff --git a/.github/workflows/skill-translation.yml b/.github/workflows/skill-translation.yml new file mode 100644 index 0000000000..73d043ac7d --- /dev/null +++ b/.github/workflows/skill-translation.yml @@ -0,0 +1,24 @@ +name: Translation Skill + +on: + issues: + types: [labeled] + +jobs: + translation: + if: github.event.label.name == 'content:translation' + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Run translation skill + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: python3 .github/skills/translation/actions/translation_action.py diff --git a/.gitignore b/.gitignore index 80544f8910..abb7604229 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ Thumbs.db ########## __pycache__/ +# Local environment config (API keys, etc.) # +############################################## +.env + # Gettext translation templates — generated at build time. # # .pot files inside translations/ subdirectories are committed so that # # Weblate can pick them up; all other .pot files are ignored. #