Files
dotfiles/projects/scripts/popup-ai-translator.py
2025-12-31 01:38:43 -08:00

249 lines
7.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Japanese Learning Assistant using OpenRouter API
Uses Google Gemini Flash 2.0 for AJATT-aligned Japanese analysis
"""
import os
import subprocess
import sys
import requests
# Configuration
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
MODEL = os.environ.get("OPENROUTER_MODEL", "google/gemini-2.0-flash-001")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
# Try to load API key from file if not in environment
if not OPENROUTER_API_KEY:
key_file = os.path.expanduser("~/.openrouterapikey")
if os.path.isfile(key_file):
with open(key_file, "r") as f:
OPENROUTER_API_KEY = f.read().strip()
SYSTEM_PROMPT = """You are my Japanese-learning assistant. Help me acquire Japanese through deep, AJATT-aligned analysis.
For every input, output exactly using clean plain text formatting:
═══════════════════════════════════════
1. JAPANESE INPUT
═══════════════════════════════════════
Repeat the original text exactly. Correct only critical OCR/punctuation errors.
═══════════════════════════════════════
2. NATURAL ENGLISH TRANSLATION
═══════════════════════════════════════
Accurate and natural. Preserve tone, formality, and nuance. Avoid literalism.
═══════════════════════════════════════
3. BREAKDOWN
═══════════════════════════════════════
For each token, provide a breakdown of the vocabulary, grammar, and nuance.
▸ Vocabulary: Part of speech + concise definition
▸ Grammar: Particles, conjugations, constructions (contextual usage)
▸ Nuance: Implied meaning, connotation, emotional tone, differences from similar expressions
Core Principles:
• Preserve native phrasing—never oversimplify
• Highlight subtle grammar, register shifts, and pragmatic implications
• Encourage pattern recognition; provide contrastive examples (e.g., ~のに vs ~けど)
• Focus on real Japanese usage
Rules:
• English explanations only (no romaji)
• Use the section dividers shown above (═══) for major sections
• Use ▸ for sub-items and • for bullet points
• Put Japanese terms in 「brackets」
• No filler text
Optional Additions (only when valuable):
• Synonyms, formality/register notes, cultural insights, common mistakes, extra native examples
Goal: Deep comprehension, natural grammar internalization, nuanced vocabulary, progress toward Japanese-only understanding."""
def show_error(message: str) -> None:
"""Display an error dialog using zenity."""
subprocess.run(
["zenity", "--error", "--text", message, "--title", "Error"],
stderr=subprocess.DEVNULL,
)
def get_clipboard() -> str:
"""Get clipboard contents using wl-paste or xclip."""
# Try wl-paste first (Wayland)
result = subprocess.run(
["wl-paste", "--no-newline"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
# Fall back to xclip (X11)
result = subprocess.run(
["xclip", "-selection", "clipboard", "-o"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
return ""
def get_input() -> str | None:
"""Get input from user via zenity entry dialog, pre-populated with clipboard."""
clipboard = get_clipboard()
cmd = [
"zenity",
"--entry",
"--title",
"Japanese Assistant",
"--text",
"Enter Japanese text to analyze (press Enter to send):",
"--width",
"600",
]
if clipboard:
cmd.extend(["--entry-text", clipboard])
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
if result.returncode != 0:
return None
return result.stdout.strip()
def show_notification(message: str, body: str) -> subprocess.Popen:
"""Show a notification using notify-send."""
return subprocess.Popen(
["notify-send", "-t", "0", "-a", "Japanese Assistant", message, body],
stderr=subprocess.DEVNULL,
)
def close_notification() -> None:
"""Close the processing notification."""
subprocess.run(
["pkill", "-f", "notify-send.*Processing.*Analyzing"],
stderr=subprocess.DEVNULL,
)
def display_result(content: str) -> None:
"""Display the result in a zenity text-info dialog."""
subprocess.run(
[
"zenity",
"--text-info",
"--title",
"Japanese Analysis",
"--width",
"900",
"--height",
"700",
"--font",
"monospace 11",
],
input=content,
text=True,
stderr=subprocess.DEVNULL,
)
def make_api_request(user_input: str) -> dict:
"""Make the API request to OpenRouter."""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"HTTP-Referer": "https://github.com/sudacode/scripts",
"X-Title": "Japanese Learning Assistant",
}
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_input},
],
"temperature": 0.7,
}
response = requests.post(API_URL, headers=headers, json=payload, timeout=60)
return response.json()
def main() -> int:
# Check for API key
if not OPENROUTER_API_KEY:
show_error("OPENROUTER_API_KEY environment variable is not set.")
return 1
# Get input from user
user_input = get_input()
if not user_input:
return 0
# Show loading notification
show_notification("Processing...", f"Analyzing: {user_input[:50]}...")
try:
# Make API request
response = make_api_request(user_input)
# Close loading notification
close_notification()
# Check for errors in response
if "error" in response:
error_msg = response["error"].get("message", "Unknown error")
show_error(error_msg)
return 1
# Extract content from response
try:
content = response["choices"][0]["message"]["content"]
except (KeyError, IndexError):
show_error("Failed to parse API response")
return 1
if not content:
show_error("Empty response from API")
return 1
# Display result
display_result(content)
except requests.RequestException as e:
close_notification()
show_error(f"API request failed: {e}")
return 1
except Exception as e:
close_notification()
show_error(f"Error: {e}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())