Files
gh-joshuaoliphant-claude-pl…/skills/mochi-creator/scripts/mochi_api.py
2025-11-30 08:28:45 +08:00

847 lines
27 KiB
Python

#!/usr/bin/env python3
"""
ABOUTME: Mochi API client for creating and managing flashcards, decks, and templates.
ABOUTME: Provides a Python interface to the Mochi.cards REST API with authentication and error handling.
Dependencies: requests
Install with: pip install requests
"""
import os
import sys
import json
from typing import Dict, List, Optional, Any
from urllib.parse import urljoin, urlencode
import requests
from requests.auth import HTTPBasicAuth
class MochiAPIError(Exception):
"""Custom exception for Mochi API errors."""
pass
class PromptQualityError(Exception):
"""Exception raised when a prompt fails quality validation."""
pass
def validate_prompt_quality(question: str, answer: str, strict: bool = False) -> Dict[str, Any]:
"""
Validate a prompt against the 5 properties of effective prompts.
Based on Andy Matuschak's research on spaced repetition prompt design.
Args:
question: The question/front of the card
answer: The answer/back of the card
strict: If True, raise exception on validation failure
Returns:
Dict with 'valid' (bool), 'issues' (list), and 'suggestions' (list)
Raises:
PromptQualityError: If strict=True and validation fails
"""
issues = []
suggestions = []
# Check 1: Focused (one detail at a time)
if " and " in question or len(answer.split(",")) > 2:
issues.append("Prompt appears unfocused (tests multiple details)")
suggestions.append("Break into separate cards, one per detail")
# Check 2: Precise (specific, not vague)
vague_words = ["interesting", "important", "good", "bad", "tell me about", "what about"]
if any(word in question.lower() for word in vague_words):
issues.append("Question uses vague language")
suggestions.append("Be specific about what you're asking for")
# Check 3: Consistent (same answer each time)
variable_prompts = ["give an example", "name one", "describe a"]
if any(phrase in question.lower() for phrase in variable_prompts):
# This is OK for creative prompts, but warn
suggestions.append("Note: This prompt may produce variable answers (advanced technique)")
# Check 4: Binary questions (usually poor)
if question.strip().startswith(("Is ", "Does ", "Can ", "Will ", "Do ", "Are ")):
issues.append("Binary question (yes/no) - produces shallow understanding")
suggestions.append("Rephrase as open-ended question starting with What/Why/How/When")
# Check 5: Pattern-matchable (too long, answerable by syntax)
if len(question) > 200:
issues.append("Question is very long - may be answerable by pattern matching")
suggestions.append("Keep questions short and simple")
# Check 6: Trivial (too easy, no retrieval)
trivial_indicators = [
"what does", "is", "acronym", "stands for",
"true or false", "correct"
]
if any(indicator in question.lower() for indicator in trivial_indicators) and len(answer) < 20:
suggestions.append("Verify this requires memory retrieval, not trivial knowledge")
valid = len(issues) == 0
result = {
"valid": valid,
"issues": issues,
"suggestions": suggestions
}
if strict and not valid:
error_msg = "Prompt quality validation failed:\n"
error_msg += "\n".join(f" - {issue}" for issue in issues)
error_msg += "\n\nSuggestions:\n"
error_msg += "\n".join(f" - {suggestion}" for suggestion in suggestions)
raise PromptQualityError(error_msg)
return result
def create_conceptual_lens_cards(
api: "MochiAPI",
concept: str,
deck_id: str,
lenses: Optional[Dict[str, str]] = None,
base_tags: Optional[List[str]] = None
) -> List[Dict]:
"""
Create multiple cards for a concept using the 5 conceptual lenses approach.
This creates robust understanding by examining a concept from multiple angles:
- Attributes: What's always/sometimes/never true?
- Similarities: How does it relate to adjacent concepts?
- Parts/Wholes: Examples, sub-concepts, categories
- Causes/Effects: What does it do? When is it used?
- Significance: Why does it matter personally?
Args:
api: MochiAPI instance
concept: The concept to create cards about
deck_id: Target deck ID
lenses: Dict mapping lens names to specific prompts (optional - will use defaults)
base_tags: Common tags for all cards (concept name will be added automatically)
Returns:
List of created card objects
Example:
>>> api = MochiAPI()
>>> cards = create_conceptual_lens_cards(
... api,
... concept="dependency injection",
... deck_id="deck123",
... lenses={
... "attributes": "Dependencies are provided from outside",
... "similarities": "Different from service locator (push vs pull)",
... "parts": "Pass DB connection to constructor instead of creating it",
... "causes": "Makes code testable by allowing mock dependencies",
... "significance": "Essential for writing testable FastAPI endpoints"
... }
... )
"""
if base_tags is None:
base_tags = []
# Add concept as tag
concept_tag = concept.lower().replace(" ", "-")
all_tags = base_tags + [concept_tag]
# Default lens questions if not provided
default_questions = {
"attributes": f"What is the core attribute of {concept}?",
"similarities": f"How does {concept} differ from similar concepts?",
"parts": f"Give a concrete example of {concept}",
"causes": f"What problem does {concept} solve?",
"significance": f"When would you use {concept} in your work?"
}
cards = []
for lens_name, question in default_questions.items():
# Use custom lens content if provided, otherwise skip if not in lenses
if lenses and lens_name not in lenses:
continue
answer = lenses.get(lens_name, "") if lenses else ""
if not answer:
# Skip if no answer provided
continue
# Create the card
content = f"# {question}\n---\n{answer}"
card = api.create_card(
content=content,
deck_id=deck_id,
manual_tags=all_tags + [lens_name]
)
cards.append(card)
return cards
def break_into_atomic_prompts(complex_prompt: str, answer: str) -> List[Dict[str, str]]:
"""
Suggest how to break a complex prompt into atomic, focused prompts.
This is a heuristic function that identifies common patterns of unfocused prompts.
Args:
complex_prompt: The unfocused question
answer: The complex answer
Returns:
List of dicts with 'question' and 'answer' keys, or empty list if can't break down
Example:
>>> prompts = break_into_atomic_prompts(
... "What are Python decorators and what syntax do they use?",
... "Functions that modify behavior, use @ syntax"
... )
>>> len(prompts)
2
"""
suggestions = []
# Pattern 1: "What are X and Y" or "What do X and Y"
if " and " in complex_prompt:
parts = complex_prompt.split(" and ")
if len(parts) == 2:
# Try to split answer as well
answer_parts = answer.split(",")
if len(answer_parts) >= 2:
suggestions.append({
"question": parts[0].strip() + "?",
"answer": answer_parts[0].strip()
})
suggestions.append({
"question": "What " + parts[1].strip(),
"answer": ", ".join(answer_parts[1:]).strip()
})
# Pattern 2: Multiple comma-separated items in answer
if "," in answer:
items = [item.strip() for item in answer.split(",")]
if len(items) > 2:
# Suggest creating one card per item
for item in items:
suggestions.append({
"question": f"{complex_prompt} (focus on {item.split()[0]})",
"answer": item,
"note": "Break into individual cards, one per item"
})
return suggestions
def create_procedural_cards(
api: "MochiAPI",
procedure_name: str,
deck_id: str,
transitions: Optional[List[Dict[str, str]]] = None,
rationales: Optional[List[Dict[str, str]]] = None,
timings: Optional[List[Dict[str, str]]] = None,
base_tags: Optional[List[str]] = None
) -> List[Dict]:
"""
Create cards for procedural knowledge focusing on transitions, rationales, and timing.
Avoids rote "step 1, step 2" memorization in favor of understanding.
Args:
api: MochiAPI instance
procedure_name: Name of the procedure/process
deck_id: Target deck ID
transitions: List of dicts with 'condition' and 'next_step'
rationales: List of dicts with 'action' and 'reason'
timings: List of dicts with 'phase' and 'duration'
base_tags: Common tags for all cards
Returns:
List of created card objects
Example:
>>> cards = create_procedural_cards(
... api,
... procedure_name="sourdough bread making",
... deck_id="deck123",
... transitions=[
... {"condition": "flour is fully hydrated", "next_step": "add salt"}
... ],
... rationales=[
... {"action": "autolyse before adding salt",
... "reason": "salt inhibits gluten development"}
... ],
... timings=[
... {"phase": "bulk fermentation", "duration": "4-6 hours at room temp"}
... ]
... )
"""
if base_tags is None:
base_tags = []
procedure_tag = procedure_name.lower().replace(" ", "-")
all_tags = base_tags + [procedure_tag]
cards = []
# Create transition cards
if transitions:
for trans in transitions:
content = f"# When do you know it's time to {trans['next_step']} in {procedure_name}?\n---\n{trans['condition']}"
card = api.create_card(
content=content,
deck_id=deck_id,
manual_tags=all_tags + ["transitions"]
)
cards.append(card)
# Create rationale cards
if rationales:
for rat in rationales:
content = f"# Why do you {rat['action']} in {procedure_name}?\n---\n{rat['reason']}"
card = api.create_card(
content=content,
deck_id=deck_id,
manual_tags=all_tags + ["rationale"]
)
cards.append(card)
# Create timing cards
if timings:
for timing in timings:
content = f"# How long does {timing['phase']} take in {procedure_name}?\n---\n{timing['duration']}"
card = api.create_card(
content=content,
deck_id=deck_id,
manual_tags=all_tags + ["timing"]
)
cards.append(card)
return cards
class MochiAPI:
"""Client for interacting with the Mochi.cards API."""
BASE_URL = "https://app.mochi.cards/api/"
def __init__(self, api_key: Optional[str] = None):
"""
Initialize the Mochi API client.
Args:
api_key: Mochi API key. If not provided, reads from MOCHI_API_KEY environment variable.
Raises:
MochiAPIError: If no API key is provided or found in environment.
"""
self.api_key = api_key or os.getenv("MOCHI_API_KEY")
if not self.api_key:
raise MochiAPIError(
"No API key provided. Set MOCHI_API_KEY environment variable or pass api_key parameter."
)
self.auth = HTTPBasicAuth(self.api_key, "")
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
def _request(self, method: str, endpoint: str, data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any:
"""
Make a request to the Mochi API.
Args:
method: HTTP method (GET, POST, DELETE)
endpoint: API endpoint path
data: Request body data
params: Query parameters
Returns:
Response data as dict or None for DELETE requests
Raises:
MochiAPIError: If the request fails
"""
url = urljoin(self.BASE_URL, endpoint.lstrip("/"))
try:
response = requests.request(
method=method,
url=url,
auth=self.auth,
headers=self.headers,
json=data,
params=params,
timeout=30
)
if response.status_code == 204:
return None
if not response.ok:
error_data = {}
try:
error_data = response.json()
except:
pass
error_msg = f"API request failed with status {response.status_code}"
if "errors" in error_data:
error_msg += f": {error_data['errors']}"
raise MochiAPIError(error_msg)
if response.text:
return response.json()
return None
except requests.RequestException as e:
raise MochiAPIError(f"Network error: {str(e)}")
# Card operations
def create_card(
self,
content: str,
deck_id: str,
template_id: Optional[str] = None,
fields: Optional[Dict[str, Dict[str, str]]] = None,
archived: bool = False,
review_reverse: bool = False,
pos: Optional[str] = None,
manual_tags: Optional[List[str]] = None
) -> Dict:
"""
Create a new card.
Args:
content: Markdown content of the card
deck_id: ID of the deck to add the card to
template_id: Optional template ID to use
fields: Optional dict of field IDs to field values
archived: Whether the card is archived
review_reverse: Whether to review in reverse order
pos: Relative position within deck (lexicographic sorting)
manual_tags: List of tags (without # prefix)
Returns:
Created card data
Example:
>>> api = MochiAPI()
>>> card = api.create_card(
... content="# What is Python?\\n---\\nA high-level programming language",
... deck_id="abc123",
... manual_tags=["programming", "python"]
... )
"""
data = {
"content": content,
"deck-id": deck_id,
"archived?": archived,
"review-reverse?": review_reverse
}
if template_id:
data["template-id"] = template_id
if fields:
data["fields"] = fields
if pos:
data["pos"] = pos
if manual_tags:
data["manual-tags"] = manual_tags
return self._request("POST", "/cards/", data=data)
def get_card(self, card_id: str) -> Dict:
"""
Retrieve a card by ID.
Args:
card_id: The card ID
Returns:
Card data
"""
return self._request("GET", f"/cards/{card_id}")
def list_cards(
self,
deck_id: Optional[str] = None,
limit: int = 10,
bookmark: Optional[str] = None
) -> Dict:
"""
List cards with optional filtering and pagination.
Args:
deck_id: Optional deck ID to filter by
limit: Number of cards per page (1-100, default 10)
bookmark: Pagination cursor from previous request
Returns:
Dict with 'docs' (list of cards) and 'bookmark' (for next page)
"""
params = {"limit": limit}
if deck_id:
params["deck-id"] = deck_id
if bookmark:
params["bookmark"] = bookmark
return self._request("GET", "/cards/", params=params)
def update_card(
self,
card_id: str,
content: Optional[str] = None,
deck_id: Optional[str] = None,
template_id: Optional[str] = None,
fields: Optional[Dict[str, Dict[str, str]]] = None,
archived: Optional[bool] = None,
trashed: Optional[str] = None,
review_reverse: Optional[bool] = None,
pos: Optional[str] = None,
manual_tags: Optional[List[str]] = None
) -> Dict:
"""
Update an existing card.
Args:
card_id: The card ID to update
content: New markdown content
deck_id: Move to different deck
template_id: Change template
fields: Update field values
archived: Archive/unarchive
trashed: ISO 8601 timestamp to trash, or None to untrash
review_reverse: Update reverse review setting
pos: Update position
manual_tags: Update tags (replaces existing)
Returns:
Updated card data
"""
data = {}
if content is not None:
data["content"] = content
if deck_id is not None:
data["deck-id"] = deck_id
if template_id is not None:
data["template-id"] = template_id
if fields is not None:
data["fields"] = fields
if archived is not None:
data["archived?"] = archived
if trashed is not None:
data["trashed?"] = trashed
if review_reverse is not None:
data["review-reverse?"] = review_reverse
if pos is not None:
data["pos"] = pos
if manual_tags is not None:
data["manual-tags"] = manual_tags
return self._request("POST", f"/cards/{card_id}", data=data)
def delete_card(self, card_id: str) -> None:
"""
Permanently delete a card.
Warning: This cannot be undone. Consider using update_card with trashed parameter for soft delete.
Args:
card_id: The card ID to delete
"""
self._request("DELETE", f"/cards/{card_id}")
# Deck operations
def create_deck(
self,
name: str,
parent_id: Optional[str] = None,
sort: Optional[int] = None,
archived: bool = False,
sort_by: str = "none",
cards_view: str = "list",
show_sides: bool = True,
sort_by_direction: bool = False,
review_reverse: bool = False
) -> Dict:
"""
Create a new deck.
Args:
name: Deck name
parent_id: Optional parent deck ID for nesting
sort: Numeric sort order
archived: Whether deck is archived
sort_by: How to sort cards (none, lexicographically, created-at, updated-at, etc.)
cards_view: Display mode (list, grid, note, column)
show_sides: Show all sides of cards
sort_by_direction: Reverse sort order
review_reverse: Review cards in reverse
Returns:
Created deck data
"""
data = {
"name": name,
"archived?": archived,
"sort-by": sort_by,
"cards-view": cards_view,
"show-sides?": show_sides,
"sort-by-direction": sort_by_direction,
"review-reverse?": review_reverse
}
if parent_id:
data["parent-id"] = parent_id
if sort is not None:
data["sort"] = sort
return self._request("POST", "/decks/", data=data)
def get_deck(self, deck_id: str) -> Dict:
"""
Retrieve a deck by ID.
Args:
deck_id: The deck ID
Returns:
Deck data
"""
return self._request("GET", f"/decks/{deck_id}")
def list_decks(self, bookmark: Optional[str] = None) -> Dict:
"""
List all decks with pagination.
Args:
bookmark: Pagination cursor from previous request
Returns:
Dict with 'docs' (list of decks) and 'bookmark' (for next page)
"""
params = {}
if bookmark:
params["bookmark"] = bookmark
return self._request("GET", "/decks/", params=params)
def update_deck(
self,
deck_id: str,
name: Optional[str] = None,
parent_id: Optional[str] = None,
sort: Optional[int] = None,
archived: Optional[bool] = None,
trashed: Optional[str] = None,
sort_by: Optional[str] = None,
cards_view: Optional[str] = None,
show_sides: Optional[bool] = None,
sort_by_direction: Optional[bool] = None,
review_reverse: Optional[bool] = None
) -> Dict:
"""
Update an existing deck.
Args:
deck_id: The deck ID to update
name: New name
parent_id: Move to different parent
sort: Update sort order
archived: Archive/unarchive
trashed: ISO 8601 timestamp to trash, or None to untrash
sort_by: Update sort method
cards_view: Update view mode
show_sides: Update show sides setting
sort_by_direction: Update sort direction
review_reverse: Update reverse review setting
Returns:
Updated deck data
"""
data = {}
if name is not None:
data["name"] = name
if parent_id is not None:
data["parent-id"] = parent_id
if sort is not None:
data["sort"] = sort
if archived is not None:
data["archived?"] = archived
if trashed is not None:
data["trashed?"] = trashed
if sort_by is not None:
data["sort-by"] = sort_by
if cards_view is not None:
data["cards-view"] = cards_view
if show_sides is not None:
data["show-sides?"] = show_sides
if sort_by_direction is not None:
data["sort-by-direction"] = sort_by_direction
if review_reverse is not None:
data["review-reverse?"] = review_reverse
return self._request("POST", f"/decks/{deck_id}", data=data)
def delete_deck(self, deck_id: str) -> None:
"""
Permanently delete a deck.
Warning: This cannot be undone. Cards and child decks are NOT deleted.
Consider using update_deck with trashed parameter for soft delete.
Args:
deck_id: The deck ID to delete
"""
self._request("DELETE", f"/decks/{deck_id}")
# Template operations
def create_template(
self,
name: str,
content: str,
fields: Dict[str, Dict[str, Any]],
pos: Optional[str] = None,
style: Optional[Dict[str, str]] = None,
options: Optional[Dict[str, bool]] = None
) -> Dict:
"""
Create a new template.
Args:
name: Template name
content: Markdown content with field placeholders like << Field name >>
fields: Dict of field definitions
pos: Position for sorting
style: Style options (e.g., text-alignment)
options: Template options (e.g., show-sides-separately?)
Returns:
Created template data
Example:
>>> fields = {
... "front": {
... "id": "front",
... "name": "Front",
... "type": "text",
... "pos": "a"
... },
... "back": {
... "id": "back",
... "name": "Back",
... "type": "text",
... "pos": "b",
... "options": {"multi-line?": True}
... }
... }
>>> template = api.create_template(
... name="Basic Flashcard",
... content="# << Front >>\\n---\\n<< Back >>",
... fields=fields
... )
"""
data = {
"name": name,
"content": content,
"fields": fields
}
if pos:
data["pos"] = pos
if style:
data["style"] = style
if options:
data["options"] = options
return self._request("POST", "/templates/", data=data)
def get_template(self, template_id: str) -> Dict:
"""
Retrieve a template by ID.
Args:
template_id: The template ID
Returns:
Template data
"""
return self._request("GET", f"/templates/{template_id}")
def list_templates(self, bookmark: Optional[str] = None) -> Dict:
"""
List all templates with pagination.
Args:
bookmark: Pagination cursor from previous request
Returns:
Dict with 'docs' (list of templates) and 'bookmark' (for next page)
"""
params = {}
if bookmark:
params["bookmark"] = bookmark
return self._request("GET", "/templates/", params=params)
def main():
"""CLI interface for testing the Mochi API."""
if len(sys.argv) < 2:
print("Usage: python mochi_api.py <command> [args...]")
print("\nCommands:")
print(" list-decks - List all decks")
print(" list-cards <deck-id> - List cards in a deck")
print(" create-deck <name> - Create a new deck")
print(" create-card <deck-id> <content> - Create a card")
sys.exit(1)
try:
api = MochiAPI()
command = sys.argv[1]
if command == "list-decks":
result = api.list_decks()
print(json.dumps(result, indent=2))
elif command == "list-cards":
if len(sys.argv) < 3:
print("Error: deck-id required")
sys.exit(1)
result = api.list_cards(deck_id=sys.argv[2])
print(json.dumps(result, indent=2))
elif command == "create-deck":
if len(sys.argv) < 3:
print("Error: deck name required")
sys.exit(1)
result = api.create_deck(name=sys.argv[2])
print(json.dumps(result, indent=2))
elif command == "create-card":
if len(sys.argv) < 4:
print("Error: deck-id and content required")
sys.exit(1)
result = api.create_card(deck_id=sys.argv[2], content=sys.argv[3])
print(json.dumps(result, indent=2))
else:
print(f"Unknown command: {command}")
sys.exit(1)
except MochiAPIError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()