diff --git a/docs/ai-file-utils.md b/docs/ai-file-utils.md new file mode 100644 index 0000000..5a8ef17 --- /dev/null +++ b/docs/ai-file-utils.md @@ -0,0 +1,77 @@ +# AI File Utils + +The `ai_file_utils` module serves as a centralized "contract" and utility service for defining and resolving actions related to AI artifacts. It allows you to separate the *definition* of actions (like "View Markdown", "Open in ChatGPT") from the *implementation* in the UI. + +This is not a standalone MkDocs plugin but a shared library that other plugins (like `resolve_md`) can import to generate standardized action lists for any documentation page. + +## 🔹 Usage + +Since this is a helper library, you do not need to add it to your `mkdocs.yml` plugins list. + +### Using in Python Code + +Import the utility class directly in your code. The primary API is `resolve_actions`, which takes page context and returns a list of fully resolved action objects. + +```python +from plugins.ai_file_utils.ai_file_utils import AIFileUtils + +# Instantiate +utils = AIFileUtils() + +# Resolve actions for a specific page context +actions = utils.resolve_actions( + page_url="https://docs.example.com/ai/pages/my-page.md", + filename="my-page.md", + content="# My Page Content..." +) +``` + +## Action Model + +The core of this plugin is the **Action Model**, defined in `ai_file_actions.json`. This JSON schema defines what actions are available and how they behave. + +### Schema Fields + +| Field | Type | Description | +| :--- | :--- | :--- | +| `type` | `string` | The category of action. Currently supports `link` (navigation) and `clipboard` (copy text). | +| `id` | `string` | Unique identifier (e.g., `view-markdown`, `open-chat-gpt`). | +| `label` | `string` | The human-readable text displayed in the UI. | +| `analyticsKey` | `string` | A standardized key for tracking usage events. | +| `href` | `string` | (Link only) The destination URL. Supports interpolation. | +| `download` | `string` | (Link only) If present, triggers a file download with this filename. | +| `clipboardContent` | `string` | (Clipboard only) The text to be copied. | +| `promptTemplate` | `string` | (LLM only) A template used to generate the `{{ prompt }}` variable. | + +### Interpolation Variables + +The module supports dynamic injection of context using `{{ variable }}` syntax. + +| Variable | Description | +| :--- | :--- | +| `{{ page_url }}` | The full URL to the resolved AI artifact (the markdown file). | +| `{{ filename }}` | The filename of the markdown file (e.g., `basics.md`). | +| `{{ content }}` | The full text content of the markdown file. | +| `{{ prompt }}` | A special variable generated by processing the `promptTemplate` and URL-encoding the result. | + +## 🔹 Adding New Actions + +To add a new action (e.g., "Open in Gemini"), you modify `plugins/ai_file_utils/ai_file_actions.json`. You do not need to write new Python code. + +### Example: Adding a New LLM + +```json +{ + "type": "link", + "id": "open-gemini", + "label": "Open in Gemini", + "href": "https://gemini.google.com/app?q={{ prompt }}", + "promptTemplate": "Read {{ page_url }} and explain it to me.", + "analyticsKey": "open_page_markdown_gemini" +} +``` + +This configuration will automatically: +1. Resolve `{{ page_url }}` in the prompt template. +2. URL-encode the result into `{{ prompt }}`. +3. Inject it into the `href`. diff --git a/plugins/ai_file_utils/__init__.py b/plugins/ai_file_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/ai_file_utils/ai_file_actions.json b/plugins/ai_file_utils/ai_file_actions.json new file mode 100644 index 0000000..5c2aed6 --- /dev/null +++ b/plugins/ai_file_utils/ai_file_actions.json @@ -0,0 +1,42 @@ +{ + "actions": [ + { + "type": "link", + "id": "view-markdown", + "label": "View Markdown", + "href": "{{ page_url }}", + "analyticsKey": "view_page_markdown" + }, + { + "type": "link", + "id": "download-markdown", + "label": "Download Markdown", + "href": "{{ page_url }}", + "download": "{{ filename }}", + "analyticsKey": "download_page_markdown" + }, + { + "type": "clipboard", + "id": "copy-markdown", + "label": "Copy Markdown", + "clipboardContent": "{{ content }}", + "analyticsKey": "copy_page_markdown" + }, + { + "type": "link", + "id": "open-chat-gpt", + "label": "Open in ChatGPT", + "href": "https://chatgpt.com/?q={{ prompt }}", + "promptTemplate": "Analyze the documentation at https://r.jina.ai/{{ page_url }}. Focus on the technical implementation details and code examples. I want to ask you questions about implementing these protocols.", + "analyticsKey": "open_page_markdown_chatgpt" + }, + { + "type": "link", + "id": "open-claude", + "label": "Open in Claude", + "href": "https://claude.ai/new?q={{ prompt }}", + "promptTemplate": "Read {{ page_url }} so I can ask questions about it.", + "analyticsKey": "open_page_markdown_claude" + } + ] +} diff --git a/plugins/ai_file_utils/ai_file_utils.py b/plugins/ai_file_utils/ai_file_utils.py new file mode 100644 index 0000000..ea136ea --- /dev/null +++ b/plugins/ai_file_utils/ai_file_utils.py @@ -0,0 +1,115 @@ +import json +import copy +import urllib.parse +from pathlib import Path +from typing import Any, Dict, List +from mkdocs.utils import log + +class AIFileUtils: + """ + A utility class that provides methods for resolving AI file actions. + This acts as a shared library/service for plugins to resolve + links, clipboard content, and LLM prompts based on a defined schema. + """ + + def __init__(self): + self._actions_schema = None + self._actions_config_path = Path(__file__).parent / "ai_file_actions.json" + + def _load_actions_schema(self): + """ + Loads the actions definition from the JSON file. + """ + try: + if self._actions_config_path.exists(): + text = self._actions_config_path.read_text(encoding="utf-8") + self._actions_schema = json.loads(text) + log.info(f"[ai_file_utils] Loaded actions schema from {self._actions_config_path}") + else: + log.warning(f"[ai_file_utils] Actions schema file not found at {self._actions_config_path}") + self._actions_schema = {"actions": []} + except json.JSONDecodeError as e: + log.error(f"[ai_file_utils] Failed to parse actions schema JSON: {e}") + self._actions_schema = {"actions": []} + except Exception as e: + log.error(f"[ai_file_utils] Unexpected error loading actions schema: {e}", exc_info=True) + self._actions_schema = {"actions": []} + + def resolve_actions(self, page_url: str, filename: str, content: str) -> List[Dict[str, Any]]: + """ + Resolves the list of actions for a given page context. + + Args: + page_url: The absolute URL to the markdown file (ai artifact). + filename: The name of the file (e.g., 'page.md'). + content: The actual text content of the markdown file. + + Returns: + A list of action dictionaries with all placeholders resolved. + """ + if not self._actions_schema: + self._load_actions_schema() + + resolved_actions = [] + raw_actions = self._actions_schema.get("actions", []) + + for action_def in raw_actions: + try: + resolved_action = self._resolve_single_action(action_def, page_url, filename, content) + resolved_actions.append(resolved_action) + except Exception as e: + log.warning(f"[ai_file_utils] Failed to resolve action {action_def.get('id')}: {e}", exc_info=True) + + return resolved_actions + + def _resolve_single_action(self, action_def: Dict[str, Any], page_url: str, filename: str, content: str) -> Dict[str, Any]: + """ + Resolves a single action definition by replacing placeholders. + """ + # Create a deep copy to avoid modifying the schema if it has nested structures + action = copy.deepcopy(action_def) + + # 1. Resolve Prompt if a template exists + prompt_text = "" + if "promptTemplate" in action: + tpl = action["promptTemplate"] + # Apply replacements to the prompt template first + # We construct a specific dict for prompt replacements to avoid circular dependency with "{{ prompt }}" + # and to handle content/url availability + prompt_replacements = { + "{{ content }}": content, + "{{ page_url }}": page_url, + "{{ filename }}": filename + } + for placeholder, replacement in prompt_replacements.items(): + if placeholder in tpl: + tpl = tpl.replace(placeholder, replacement) + prompt_text = tpl + + # Remove the template from the output as it's processed + action.pop("promptTemplate") + + # 2. Prepare Context Variables + # URL encode the prompt for use in query parameters + encoded_prompt = urllib.parse.quote_plus(prompt_text) + + replacements = { + "{{ page_url }}": page_url, + "{{ filename }}": filename, + "{{ content }}": content, # Be careful with large content in attributes, but for clipboard it's needed + "{{ prompt }}": encoded_prompt + } + + # 3. Interpolate values into specific fields + # Fields that support interpolation + target_fields = ["href", "download", "clipboardContent"] + + for field in target_fields: + if field in action and isinstance(action[field], str): + val = action[field] + for placeholder, replacement in replacements.items(): + if placeholder in val: + val = val.replace(placeholder, replacement) + action[field] = val + + return action diff --git a/tests/ai_file_utils/test_ai_file_utils.py b/tests/ai_file_utils/test_ai_file_utils.py new file mode 100644 index 0000000..b83eabb --- /dev/null +++ b/tests/ai_file_utils/test_ai_file_utils.py @@ -0,0 +1,120 @@ +import pytest +from plugins.ai_file_utils.ai_file_utils import AIFileUtils + +class TestAIFileUtils: + def test_resolve_actions_usage(self): + """ + Demonstrates how to use the AIFileUtils to resolve actions. + """ + # 1. Instantiate the utility class + utils = AIFileUtils() + + # 2. (Optional) Initialize configuration - redundant here as it loads lazily + # utils._load_actions_schema() + + # 3. Define the context for a specific page + # This data usually comes from the processing loop in resolve_md + page_url = "https://docs.polkadot.com/ai/pages/basics.md" + filename = "basics.md" + content = "# Polkadot Basics\n\nPolkadot is a sharded protocol." + + # 4. Call the public API: resolve_actions + actions = utils.resolve_actions(page_url, filename, content) + + # Usage Verification: + # Check that we got a list back + assert isinstance(actions, list) + assert len(actions) > 0 + + # Inspect the "View Markdown" action + view_action = next(a for a in actions if a["id"] == "view-markdown") + assert view_action["href"] == "https://docs.polkadot.com/ai/pages/basics.md" + + # Inspect the "Download Markdown" action (check download attribute interpolation) + download_action = next(a for a in actions if a["id"] == "download-markdown") + assert download_action["download"] == "basics.md" + + # Inspect the "Copy Markdown" action + copy_action = next(a for a in actions if a["id"] == "copy-markdown") + assert copy_action["clipboardContent"] == content + + # Inspect the "ChatGPT" action (check prompt encoding) + chatgpt_action = next(a for a in actions if a["id"] == "open-chat-gpt") + # The prompt should be encoded in the URL + assert "chatgpt.com" in chatgpt_action["href"] + # Should contain encoded reference to jina.ai (part of the prompt now) + assert "r.jina.ai" in chatgpt_action["href"] + + # Inspect the "Claude" action + claude_action = next(a for a in actions if a["id"] == "open-claude") + assert "claude.ai" in claude_action["href"] + # Should contain encoded page url as it's part of the prompt + assert "docs.polkadot.com" in claude_action["href"] + + # This would be the structure consumed by the UI generator + print("\n--- Resolved Actions Example ---") + for action in actions: + print(f"Action ID: {action['id']}") + print(f" Type: {action['type']}") + print(f" Label: {action['label']}") + if "href" in action: + print(f" Href: {action['href'][:50]}...") # Truncated for display + if "clipboardContent" in action: + print(f" Clipboard: {action['clipboardContent'][:20]}...") + + def test_missing_schema_file(self, tmp_path, caplog): + """Test behavior when schema file is missing.""" + utils = AIFileUtils() + # Override path to non-existent file + utils._actions_config_path = tmp_path / "non_existent.json" + + # Should return empty list, not crash. Logs warning internally. + actions = utils.resolve_actions("url", "file", "content") + + # Verify warning log + assert "Actions schema file not found" in caplog.text + assert actions == [] + + def test_malformed_json_schema(self, tmp_path, caplog): + """Test behavior when schema file contains invalid JSON.""" + utils = AIFileUtils() + # Create bad JSON file + bad_file = tmp_path / "bad.json" + bad_file.write_text("{ not valid json ", encoding="utf-8") + utils._actions_config_path = bad_file + + # Should return empty list. Logs error internally. + actions = utils.resolve_actions("url", "file", "content") + + # Verify error log + assert "Failed to parse actions schema JSON" in caplog.text + assert actions == [] + + def test_action_resolution_failure(self, caplog): + """Test that one bad action doesn't crash the whole list.""" + utils = AIFileUtils() + # Manually set schema with one good and one bad action (bad promptTemplate type) + utils._actions_schema = { + "actions": [ + { + "id": "good-action", + "type": "link", + "href": "{{ page_url }}" + }, + { + "id": "bad-action", + "type": "link", + "promptTemplate": 123 # This will cause AttributeError on replace() + } + ] + } + + actions = utils.resolve_actions("http://example.com", "test.md", "content") + + # We should get the good action + assert len(actions) == 1 + assert actions[0]["id"] == "good-action" + + # We should see a warning for the bad action + assert "Failed to resolve action bad-action" in caplog.text + diff --git a/tests/ai_file_utils/test_plugin.py b/tests/ai_file_utils/test_plugin.py new file mode 100644 index 0000000..b83eabb --- /dev/null +++ b/tests/ai_file_utils/test_plugin.py @@ -0,0 +1,120 @@ +import pytest +from plugins.ai_file_utils.ai_file_utils import AIFileUtils + +class TestAIFileUtils: + def test_resolve_actions_usage(self): + """ + Demonstrates how to use the AIFileUtils to resolve actions. + """ + # 1. Instantiate the utility class + utils = AIFileUtils() + + # 2. (Optional) Initialize configuration - redundant here as it loads lazily + # utils._load_actions_schema() + + # 3. Define the context for a specific page + # This data usually comes from the processing loop in resolve_md + page_url = "https://docs.polkadot.com/ai/pages/basics.md" + filename = "basics.md" + content = "# Polkadot Basics\n\nPolkadot is a sharded protocol." + + # 4. Call the public API: resolve_actions + actions = utils.resolve_actions(page_url, filename, content) + + # Usage Verification: + # Check that we got a list back + assert isinstance(actions, list) + assert len(actions) > 0 + + # Inspect the "View Markdown" action + view_action = next(a for a in actions if a["id"] == "view-markdown") + assert view_action["href"] == "https://docs.polkadot.com/ai/pages/basics.md" + + # Inspect the "Download Markdown" action (check download attribute interpolation) + download_action = next(a for a in actions if a["id"] == "download-markdown") + assert download_action["download"] == "basics.md" + + # Inspect the "Copy Markdown" action + copy_action = next(a for a in actions if a["id"] == "copy-markdown") + assert copy_action["clipboardContent"] == content + + # Inspect the "ChatGPT" action (check prompt encoding) + chatgpt_action = next(a for a in actions if a["id"] == "open-chat-gpt") + # The prompt should be encoded in the URL + assert "chatgpt.com" in chatgpt_action["href"] + # Should contain encoded reference to jina.ai (part of the prompt now) + assert "r.jina.ai" in chatgpt_action["href"] + + # Inspect the "Claude" action + claude_action = next(a for a in actions if a["id"] == "open-claude") + assert "claude.ai" in claude_action["href"] + # Should contain encoded page url as it's part of the prompt + assert "docs.polkadot.com" in claude_action["href"] + + # This would be the structure consumed by the UI generator + print("\n--- Resolved Actions Example ---") + for action in actions: + print(f"Action ID: {action['id']}") + print(f" Type: {action['type']}") + print(f" Label: {action['label']}") + if "href" in action: + print(f" Href: {action['href'][:50]}...") # Truncated for display + if "clipboardContent" in action: + print(f" Clipboard: {action['clipboardContent'][:20]}...") + + def test_missing_schema_file(self, tmp_path, caplog): + """Test behavior when schema file is missing.""" + utils = AIFileUtils() + # Override path to non-existent file + utils._actions_config_path = tmp_path / "non_existent.json" + + # Should return empty list, not crash. Logs warning internally. + actions = utils.resolve_actions("url", "file", "content") + + # Verify warning log + assert "Actions schema file not found" in caplog.text + assert actions == [] + + def test_malformed_json_schema(self, tmp_path, caplog): + """Test behavior when schema file contains invalid JSON.""" + utils = AIFileUtils() + # Create bad JSON file + bad_file = tmp_path / "bad.json" + bad_file.write_text("{ not valid json ", encoding="utf-8") + utils._actions_config_path = bad_file + + # Should return empty list. Logs error internally. + actions = utils.resolve_actions("url", "file", "content") + + # Verify error log + assert "Failed to parse actions schema JSON" in caplog.text + assert actions == [] + + def test_action_resolution_failure(self, caplog): + """Test that one bad action doesn't crash the whole list.""" + utils = AIFileUtils() + # Manually set schema with one good and one bad action (bad promptTemplate type) + utils._actions_schema = { + "actions": [ + { + "id": "good-action", + "type": "link", + "href": "{{ page_url }}" + }, + { + "id": "bad-action", + "type": "link", + "promptTemplate": 123 # This will cause AttributeError on replace() + } + ] + } + + actions = utils.resolve_actions("http://example.com", "test.md", "content") + + # We should get the good action + assert len(actions) == 1 + assert actions[0]["id"] == "good-action" + + # We should see a warning for the bad action + assert "Failed to resolve action bad-action" in caplog.text +