Skip to content
73 changes: 73 additions & 0 deletions docs/ai-file-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 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..."
)
```

### 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`.
Empty file.
42 changes: 42 additions & 0 deletions plugins/ai_file_utils/ai_file_actions.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
115 changes: 115 additions & 0 deletions plugins/ai_file_utils/ai_file_utils.py
Original file line number Diff line number Diff line change
@@ -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
120 changes: 120 additions & 0 deletions tests/ai_file_utils/test_ai_file_utils.py
Original file line number Diff line number Diff line change
@@ -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

Loading