Spaces:
Sleeping
Sleeping
willie
commited on
Commit
·
f5b3d19
1
Parent(s):
1a20172
Intitial commit
Browse files- DEPLOYMENT.md +189 -0
- app.py +370 -0
- requirements.txt +3 -0
- utils.py +214 -0
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment Guide
|
| 2 |
+
|
| 3 |
+
## Deploying to Hugging Face Spaces (FREE)
|
| 4 |
+
|
| 5 |
+
Hugging Face Spaces is the recommended free hosting platform for this Gradio app. Users will provide their own Anthropic API keys through the UI.
|
| 6 |
+
|
| 7 |
+
### Prerequisites
|
| 8 |
+
|
| 9 |
+
- A [Hugging Face](https://huggingface.co/) account (free)
|
| 10 |
+
- A GitHub account (to connect your repository)
|
| 11 |
+
|
| 12 |
+
### Step 1: Prepare Your GitHub Repository
|
| 13 |
+
|
| 14 |
+
1. Push this code to a GitHub repository:
|
| 15 |
+
```bash
|
| 16 |
+
git push origin main
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
2. Make sure your repository includes:
|
| 20 |
+
- `app.py`
|
| 21 |
+
- `utils.py`
|
| 22 |
+
- `requirements.txt`
|
| 23 |
+
- `README.md`
|
| 24 |
+
- `.gitignore` (to exclude logs and cache)
|
| 25 |
+
|
| 26 |
+
### Step 2: Create a Hugging Face Space
|
| 27 |
+
|
| 28 |
+
1. Go to [Hugging Face Spaces](https://huggingface.co/spaces)
|
| 29 |
+
|
| 30 |
+
2. Click **"Create new Space"**
|
| 31 |
+
|
| 32 |
+
3. Fill in the details:
|
| 33 |
+
- **Space name**: `sheet-music-metaphor-analyzer` (or your preferred name)
|
| 34 |
+
- **License**: Choose your preferred license (MIT recommended)
|
| 35 |
+
- **Select the Space SDK**: Choose **Gradio**
|
| 36 |
+
- **Space hardware**: Choose **CPU basic** (free)
|
| 37 |
+
- **Visibility**: Public (so others can use it)
|
| 38 |
+
|
| 39 |
+
4. Click **"Create Space"**
|
| 40 |
+
|
| 41 |
+
### Step 3: Connect to GitHub
|
| 42 |
+
|
| 43 |
+
You have two options:
|
| 44 |
+
|
| 45 |
+
#### Option A: Direct Git Push (Recommended)
|
| 46 |
+
|
| 47 |
+
1. After creating the Space, you'll see a Git URL like:
|
| 48 |
+
```
|
| 49 |
+
https://huggingface.co/spaces/YOUR-USERNAME/sheet-music-metaphor-analyzer
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
2. Clone the empty Space repository:
|
| 53 |
+
```bash
|
| 54 |
+
git clone https://huggingface.co/spaces/YOUR-USERNAME/sheet-music-metaphor-analyzer
|
| 55 |
+
cd sheet-music-metaphor-analyzer
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
3. Copy your files into this directory:
|
| 59 |
+
```bash
|
| 60 |
+
cp /path/to/your/app.py .
|
| 61 |
+
cp /path/to/your/utils.py .
|
| 62 |
+
cp /path/to/your/requirements.txt .
|
| 63 |
+
cp /path/to/your/README.md .
|
| 64 |
+
cp /path/to/your/.gitignore .
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
4. Commit and push:
|
| 68 |
+
```bash
|
| 69 |
+
git add .
|
| 70 |
+
git commit -m "Initial deployment"
|
| 71 |
+
git push
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
#### Option B: GitHub Sync
|
| 75 |
+
|
| 76 |
+
1. In your Space settings, click on **"Files and versions"** tab
|
| 77 |
+
|
| 78 |
+
2. Click **"Add file"** > **"Upload files"**
|
| 79 |
+
|
| 80 |
+
3. Upload all required files:
|
| 81 |
+
- `app.py`
|
| 82 |
+
- `utils.py`
|
| 83 |
+
- `requirements.txt`
|
| 84 |
+
- `README.md`
|
| 85 |
+
|
| 86 |
+
4. Commit the changes
|
| 87 |
+
|
| 88 |
+
### Step 4: Verify Deployment
|
| 89 |
+
|
| 90 |
+
1. Wait 1-2 minutes for the Space to build and start
|
| 91 |
+
|
| 92 |
+
2. Your app will be available at:
|
| 93 |
+
```
|
| 94 |
+
https://huggingface.co/spaces/YOUR-USERNAME/sheet-music-metaphor-analyzer
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
3. Test the app:
|
| 98 |
+
- Upload a sheet music image
|
| 99 |
+
- Enter your Anthropic API key
|
| 100 |
+
- Click "Analyze Music"
|
| 101 |
+
- Verify the results appear correctly
|
| 102 |
+
|
| 103 |
+
### Step 5: Share Your Space
|
| 104 |
+
|
| 105 |
+
Your Space is now live! Share the URL with others:
|
| 106 |
+
```
|
| 107 |
+
https://huggingface.co/spaces/YOUR-USERNAME/sheet-music-metaphor-analyzer
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
Users will need their own Anthropic API keys to use the app.
|
| 111 |
+
|
| 112 |
+
## Alternative Deployment Options
|
| 113 |
+
|
| 114 |
+
### Railway (Limited Free Tier)
|
| 115 |
+
|
| 116 |
+
1. Sign up at [Railway](https://railway.app/)
|
| 117 |
+
2. Create a new project from GitHub
|
| 118 |
+
3. Add a start command: `python app.py`
|
| 119 |
+
4. Deploy
|
| 120 |
+
|
| 121 |
+
### Render (Limited Free Tier)
|
| 122 |
+
|
| 123 |
+
1. Sign up at [Render](https://render.com/)
|
| 124 |
+
2. Create a new Web Service
|
| 125 |
+
3. Connect your GitHub repository
|
| 126 |
+
4. Set build command: `pip install -r requirements.txt`
|
| 127 |
+
5. Set start command: `python app.py`
|
| 128 |
+
6. Deploy
|
| 129 |
+
|
| 130 |
+
## Troubleshooting
|
| 131 |
+
|
| 132 |
+
### Space Won't Start
|
| 133 |
+
|
| 134 |
+
- Check the logs in the Space's "Logs" tab
|
| 135 |
+
- Verify `requirements.txt` has correct package versions
|
| 136 |
+
- Ensure `app.py` has `if __name__ == "__main__": main()`
|
| 137 |
+
|
| 138 |
+
### Import Errors
|
| 139 |
+
|
| 140 |
+
- Make sure all files are in the root directory of the Space
|
| 141 |
+
- Verify `utils.py` is uploaded
|
| 142 |
+
- Check that `requirements.txt` includes all dependencies
|
| 143 |
+
|
| 144 |
+
### API Key Issues
|
| 145 |
+
|
| 146 |
+
- The app now requires users to input their own API key
|
| 147 |
+
- API keys are not stored or logged
|
| 148 |
+
- Users need to get keys from [Anthropic Console](https://console.anthropic.com/)
|
| 149 |
+
|
| 150 |
+
## Security Notes
|
| 151 |
+
|
| 152 |
+
- Never commit API keys to the repository
|
| 153 |
+
- Users provide their own keys through the UI
|
| 154 |
+
- Keys are passed only in-memory and not persisted
|
| 155 |
+
- Logs directory is gitignored to prevent accidental data exposure
|
| 156 |
+
|
| 157 |
+
## Updating Your Deployment
|
| 158 |
+
|
| 159 |
+
To update your deployed Space:
|
| 160 |
+
|
| 161 |
+
1. Make changes to your local files
|
| 162 |
+
2. Commit changes:
|
| 163 |
+
```bash
|
| 164 |
+
git add .
|
| 165 |
+
git commit -m "Update: description of changes"
|
| 166 |
+
```
|
| 167 |
+
3. Push to Hugging Face Space:
|
| 168 |
+
```bash
|
| 169 |
+
git push
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
The Space will automatically rebuild and redeploy.
|
| 173 |
+
|
| 174 |
+
## Cost Considerations
|
| 175 |
+
|
| 176 |
+
- **Hugging Face Spaces**: Completely free for CPU-based Gradio apps
|
| 177 |
+
- **API Usage**: Users pay for their own Anthropic API usage
|
| 178 |
+
- **Rate Limits**: Consider Anthropic's rate limits for API usage
|
| 179 |
+
|
| 180 |
+
## Getting an Anthropic API Key
|
| 181 |
+
|
| 182 |
+
Users will need to:
|
| 183 |
+
1. Go to [Anthropic Console](https://console.anthropic.com/)
|
| 184 |
+
2. Sign up or log in
|
| 185 |
+
3. Navigate to API Keys section
|
| 186 |
+
4. Create a new API key
|
| 187 |
+
5. Copy and paste into the app
|
| 188 |
+
|
| 189 |
+
API keys start with `sk-ant-api-` and should be kept secure.
|
app.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Sheet Music Metaphor Analyzer - Main Gradio Application
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import io
|
| 7 |
+
import os
|
| 8 |
+
from typing import Optional, Tuple
|
| 9 |
+
|
| 10 |
+
import anthropic
|
| 11 |
+
import gradio as gr
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
from utils import (
|
| 15 |
+
parse_and_validate_json,
|
| 16 |
+
save_analysis_log,
|
| 17 |
+
setup_logging,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# Initialize logger
|
| 21 |
+
logger = setup_logging()
|
| 22 |
+
|
| 23 |
+
# Claude API prompt
|
| 24 |
+
ANALYSIS_PROMPT = """You are an experienced music conductor and teacher analyzing sheet music to provide performance guidance.
|
| 25 |
+
|
| 26 |
+
Follow this step-by-step process:
|
| 27 |
+
|
| 28 |
+
STEP 1: CONDUCTOR ANALYSIS
|
| 29 |
+
As an experienced conductor, examine the musical notation carefully and describe:
|
| 30 |
+
- mood: The emotional tone and feeling the notation suggests
|
| 31 |
+
- gesture: The physical conducting gesture or body movement this would inspire
|
| 32 |
+
- motion: The type of movement quality (e.g., flowing, crisp, sustained, bouncing)
|
| 33 |
+
|
| 34 |
+
STEP 2: NOTATION INSIGHTS
|
| 35 |
+
Identify 2-4 specific aspects of the notation that caught your attention and influenced your interpretation. These might be dynamics, articulation, tempo markings, phrase shapes, rhythmic patterns, or harmonic progressions. Write these as clear observations that help explain how you arrived at your interpretation.
|
| 36 |
+
|
| 37 |
+
STEP 3: INSTRUCTIONAL METAPHORS
|
| 38 |
+
Based on your analysis, create exactly 3 simple, direct instructional metaphors for the performer. Each should:
|
| 39 |
+
- Start with a phrase like "Play this like...", "Think of...", "Imagine...", or similar
|
| 40 |
+
- Use simple, everyday imagery that's easy to grasp
|
| 41 |
+
- Be direct and practical, not flowery or ornate
|
| 42 |
+
- Avoid technical music terminology
|
| 43 |
+
- Focus on feeling and physicality
|
| 44 |
+
- Keep it grounded - prefer "walking through tall grass" over "dancing through celestial meadows"
|
| 45 |
+
|
| 46 |
+
STEP 4: FINAL METAPHOR
|
| 47 |
+
Synthesize everything above into one concise, simple instructional metaphor. Keep it direct and practical - a clear image the performer can immediately use. Avoid overly poetic or elaborate language.
|
| 48 |
+
|
| 49 |
+
Return ONLY valid JSON matching this exact schema:
|
| 50 |
+
|
| 51 |
+
{
|
| 52 |
+
"mood": "string",
|
| 53 |
+
"gesture": "string",
|
| 54 |
+
"motion": "string",
|
| 55 |
+
"notation_details": ["observation 1", "observation 2", "..."],
|
| 56 |
+
"instructional_metaphors": ["metaphor 1", "metaphor 2", "metaphor 3"],
|
| 57 |
+
"final_metaphor": "one simple, direct metaphor"
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
Remember: Return ONLY the JSON object, no additional text or explanation."""
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def resize_image(image: Image.Image, max_width: int = 1400) -> Image.Image:
|
| 64 |
+
"""
|
| 65 |
+
Resize image to max width while maintaining aspect ratio.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
image: PIL Image to resize
|
| 69 |
+
max_width: Maximum width in pixels
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Resized PIL Image
|
| 73 |
+
"""
|
| 74 |
+
if image.width <= max_width:
|
| 75 |
+
return image
|
| 76 |
+
|
| 77 |
+
ratio = max_width / image.width
|
| 78 |
+
new_height = int(image.height * ratio)
|
| 79 |
+
return image.resize((max_width, new_height), Image.Resampling.LANCZOS)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def image_to_base64(image: Image.Image) -> str:
|
| 83 |
+
"""
|
| 84 |
+
Convert PIL Image to base64 string.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
image: PIL Image to convert
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Base64 encoded string
|
| 91 |
+
"""
|
| 92 |
+
buffered = io.BytesIO()
|
| 93 |
+
image.save(buffered, format="PNG")
|
| 94 |
+
return base64.b64encode(buffered.getvalue()).decode("utf-8")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def analyze_sheet_music(
|
| 98 |
+
image: Optional[Image.Image],
|
| 99 |
+
api_key: Optional[str] = None
|
| 100 |
+
) -> Tuple[str, str, str]:
|
| 101 |
+
"""
|
| 102 |
+
Analyze sheet music image using Claude Vision API.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
image: PIL Image of sheet music
|
| 106 |
+
api_key: Optional API key (uses env var if not provided)
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
Tuple of (final_metaphor_html, json_output, error_message)
|
| 110 |
+
"""
|
| 111 |
+
if image is None:
|
| 112 |
+
return "", "", "Please upload an image first."
|
| 113 |
+
|
| 114 |
+
# Get API key
|
| 115 |
+
if not api_key:
|
| 116 |
+
api_key = os.getenv("ANTHROPIC_API_KEY")
|
| 117 |
+
|
| 118 |
+
if not api_key:
|
| 119 |
+
error_msg = "ANTHROPIC_API_KEY not found in environment variables."
|
| 120 |
+
logger.error(error_msg)
|
| 121 |
+
return "", "", error_msg
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
# Resize image
|
| 125 |
+
logger.info(f"Processing image of size {image.size}")
|
| 126 |
+
resized_image = resize_image(image)
|
| 127 |
+
logger.info(f"Resized to {resized_image.size}")
|
| 128 |
+
|
| 129 |
+
# Convert to base64
|
| 130 |
+
image_b64 = image_to_base64(resized_image)
|
| 131 |
+
|
| 132 |
+
# Initialize Anthropic client
|
| 133 |
+
client = anthropic.Anthropic(api_key=api_key)
|
| 134 |
+
|
| 135 |
+
# First attempt
|
| 136 |
+
logger.info("Sending request to Claude Vision API...")
|
| 137 |
+
response = client.messages.create(
|
| 138 |
+
model="claude-sonnet-4-20250514",
|
| 139 |
+
max_tokens=1024,
|
| 140 |
+
temperature=0.2,
|
| 141 |
+
messages=[
|
| 142 |
+
{
|
| 143 |
+
"role": "user",
|
| 144 |
+
"content": [
|
| 145 |
+
{
|
| 146 |
+
"type": "image",
|
| 147 |
+
"source": {
|
| 148 |
+
"type": "base64",
|
| 149 |
+
"media_type": "image/png",
|
| 150 |
+
"data": image_b64,
|
| 151 |
+
},
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"type": "text",
|
| 155 |
+
"text": ANALYSIS_PROMPT
|
| 156 |
+
}
|
| 157 |
+
],
|
| 158 |
+
}
|
| 159 |
+
],
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
raw_response = response.content[0].text
|
| 163 |
+
logger.info(f"Received response: {raw_response[:200]}...")
|
| 164 |
+
|
| 165 |
+
# Parse and validate
|
| 166 |
+
parsed_data, error = parse_and_validate_json(raw_response, logger)
|
| 167 |
+
|
| 168 |
+
# If parsing failed, retry with stricter instruction
|
| 169 |
+
if parsed_data is None and error:
|
| 170 |
+
logger.warning(f"First attempt failed: {error}. Retrying with stricter prompt...")
|
| 171 |
+
|
| 172 |
+
retry_response = client.messages.create(
|
| 173 |
+
model="claude-sonnet-4-20250514",
|
| 174 |
+
max_tokens=1024,
|
| 175 |
+
temperature=0.1,
|
| 176 |
+
messages=[
|
| 177 |
+
{
|
| 178 |
+
"role": "user",
|
| 179 |
+
"content": [
|
| 180 |
+
{
|
| 181 |
+
"type": "image",
|
| 182 |
+
"source": {
|
| 183 |
+
"type": "base64",
|
| 184 |
+
"media_type": "image/png",
|
| 185 |
+
"data": image_b64,
|
| 186 |
+
},
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
"type": "text",
|
| 190 |
+
"text": ANALYSIS_PROMPT
|
| 191 |
+
}
|
| 192 |
+
],
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"role": "assistant",
|
| 196 |
+
"content": raw_response
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"role": "user",
|
| 200 |
+
"content": "Return valid JSON only, no prose. Use the exact schema structure provided."
|
| 201 |
+
}
|
| 202 |
+
],
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
raw_response = retry_response.content[0].text
|
| 206 |
+
parsed_data, error = parse_and_validate_json(raw_response, logger)
|
| 207 |
+
|
| 208 |
+
# Save log
|
| 209 |
+
save_analysis_log(
|
| 210 |
+
image_path="uploaded_image",
|
| 211 |
+
raw_response=raw_response,
|
| 212 |
+
parsed_data=parsed_data,
|
| 213 |
+
error=error
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Handle results
|
| 217 |
+
if parsed_data is None:
|
| 218 |
+
return "", raw_response, f"Failed to parse response: {error}"
|
| 219 |
+
|
| 220 |
+
# Format outputs
|
| 221 |
+
final_metaphor_html = f"""
|
| 222 |
+
<div style="padding: 30px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 223 |
+
border-radius: 15px; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.3);">
|
| 224 |
+
<h2 style="color: white; margin-bottom: 20px; font-size: 24px; font-weight: 300;">
|
| 225 |
+
Performance Guidance
|
| 226 |
+
</h2>
|
| 227 |
+
<p style="color: white; font-size: 32px; font-weight: 500; line-height: 1.5;
|
| 228 |
+
font-style: italic; margin: 0;">
|
| 229 |
+
{parsed_data['final_metaphor']}
|
| 230 |
+
</p>
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
<div style="margin-top: 25px; padding: 20px; background: #f8f9fa;
|
| 234 |
+
border-radius: 10px; border-left: 4px solid #667eea;">
|
| 235 |
+
<h3 style="margin-top: 0; color: #333; font-size: 18px;">Conductor Analysis</h3>
|
| 236 |
+
<p style="margin: 10px 0;"><strong>Mood:</strong> {parsed_data['mood']}</p>
|
| 237 |
+
<p style="margin: 10px 0;"><strong>Gesture:</strong> {parsed_data['gesture']}</p>
|
| 238 |
+
<p style="margin: 10px 0;"><strong>Motion:</strong> {parsed_data['motion']}</p>
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
<div style="margin-top: 20px; padding: 20px; background: #e7f3ff;
|
| 242 |
+
border-radius: 10px; border-left: 4px solid #2196f3;">
|
| 243 |
+
<h3 style="margin-top: 0; color: #333; font-size: 18px;">What the Conductor Noticed</h3>
|
| 244 |
+
<ul style="margin: 10px 0; padding-left: 20px; line-height: 1.8;">
|
| 245 |
+
{"".join(f'<li>{detail}</li>' for detail in parsed_data['notation_details'])}
|
| 246 |
+
</ul>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<div style="margin-top: 20px; padding: 20px; background: #fff3cd;
|
| 250 |
+
border-radius: 10px; border-left: 4px solid #ffc107;">
|
| 251 |
+
<h3 style="margin-top: 0; color: #333; font-size: 18px;">Instructional Metaphors</h3>
|
| 252 |
+
<ul style="margin: 10px 0; padding-left: 20px; line-height: 1.8;">
|
| 253 |
+
{"".join(f'<li>{m}</li>' for m in parsed_data['instructional_metaphors'])}
|
| 254 |
+
</ul>
|
| 255 |
+
</div>
|
| 256 |
+
"""
|
| 257 |
+
|
| 258 |
+
import json
|
| 259 |
+
json_output = json.dumps(parsed_data, indent=2, ensure_ascii=False)
|
| 260 |
+
|
| 261 |
+
logger.info("Analysis completed successfully")
|
| 262 |
+
return final_metaphor_html, json_output, ""
|
| 263 |
+
|
| 264 |
+
except anthropic.APIError as e:
|
| 265 |
+
error_msg = f"API Error: {str(e)}"
|
| 266 |
+
logger.error(error_msg)
|
| 267 |
+
return "", "", error_msg
|
| 268 |
+
except Exception as e:
|
| 269 |
+
error_msg = f"Unexpected error: {str(e)}"
|
| 270 |
+
logger.error(error_msg, exc_info=True)
|
| 271 |
+
return "", "", error_msg
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def create_ui() -> gr.Blocks:
|
| 275 |
+
"""
|
| 276 |
+
Create and configure the Gradio UI.
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
Configured Gradio Blocks interface
|
| 280 |
+
"""
|
| 281 |
+
with gr.Blocks(
|
| 282 |
+
title="Sheet Music Metaphor Analyzer",
|
| 283 |
+
theme=gr.themes.Soft()
|
| 284 |
+
) as demo:
|
| 285 |
+
gr.Markdown(
|
| 286 |
+
"""
|
| 287 |
+
# Sheet Music Metaphor Analyzer
|
| 288 |
+
|
| 289 |
+
Upload a photo of sheet music and get poetic, sensory performance guidance from an AI conductor.
|
| 290 |
+
|
| 291 |
+
**Note:** You need your own [Anthropic API key](https://console.anthropic.com/) to use this app.
|
| 292 |
+
"""
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
with gr.Row():
|
| 296 |
+
with gr.Column(scale=1):
|
| 297 |
+
image_input = gr.Image(
|
| 298 |
+
type="pil",
|
| 299 |
+
label="Upload Sheet Music Photo",
|
| 300 |
+
height=400
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
api_key_input = gr.Textbox(
|
| 304 |
+
label="Anthropic API Key (required)",
|
| 305 |
+
type="password",
|
| 306 |
+
placeholder="sk-ant-api-..."
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
analyze_btn = gr.Button(
|
| 310 |
+
"Analyze Music",
|
| 311 |
+
variant="primary",
|
| 312 |
+
size="lg"
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
with gr.Column(scale=1):
|
| 316 |
+
result_html = gr.HTML(label="Result")
|
| 317 |
+
|
| 318 |
+
error_output = gr.Textbox(
|
| 319 |
+
label="Errors",
|
| 320 |
+
visible=True,
|
| 321 |
+
interactive=False,
|
| 322 |
+
lines=2
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
with gr.Accordion("Debug: Full JSON Response", open=False):
|
| 326 |
+
json_output = gr.Code(
|
| 327 |
+
label="Raw JSON",
|
| 328 |
+
language="json",
|
| 329 |
+
lines=15
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
# Event handlers
|
| 333 |
+
analyze_btn.click(
|
| 334 |
+
fn=analyze_sheet_music,
|
| 335 |
+
inputs=[image_input, api_key_input],
|
| 336 |
+
outputs=[result_html, json_output, error_output]
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
gr.Markdown(
|
| 340 |
+
"""
|
| 341 |
+
---
|
| 342 |
+
**Tips:**
|
| 343 |
+
- Upload clear photos of printed sheet music
|
| 344 |
+
- Works best with short musical phrases
|
| 345 |
+
- The app will provide sensory metaphors to guide your performance
|
| 346 |
+
"""
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
return demo
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def main():
|
| 353 |
+
"""
|
| 354 |
+
Launch the Gradio application.
|
| 355 |
+
"""
|
| 356 |
+
logger.info("Starting Sheet Music Metaphor Analyzer...")
|
| 357 |
+
|
| 358 |
+
# Check for API key
|
| 359 |
+
if not os.getenv("ANTHROPIC_API_KEY"):
|
| 360 |
+
logger.warning(
|
| 361 |
+
"ANTHROPIC_API_KEY not found in environment. "
|
| 362 |
+
"Users will need to provide it in the UI."
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
demo = create_ui()
|
| 366 |
+
demo.launch(share=True)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
if __name__ == "__main__":
|
| 370 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
anthropic>=0.39.0
|
| 2 |
+
gradio>=4.0.0
|
| 3 |
+
Pillow>=10.0.0
|
utils.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility functions for JSON validation, repair, and logging.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, Optional
|
| 11 |
+
|
| 12 |
+
# Expected JSON schema
|
| 13 |
+
SCHEMA = {
|
| 14 |
+
"mood": str,
|
| 15 |
+
"gesture": str,
|
| 16 |
+
"motion": str,
|
| 17 |
+
"notation_details": list,
|
| 18 |
+
"instructional_metaphors": list,
|
| 19 |
+
"final_metaphor": str
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def setup_logging(log_dir: str = "./logs") -> logging.Logger:
|
| 24 |
+
"""
|
| 25 |
+
Set up logging to both file and console.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
log_dir: Directory to store log files
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Configured logger instance
|
| 32 |
+
"""
|
| 33 |
+
Path(log_dir).mkdir(exist_ok=True)
|
| 34 |
+
|
| 35 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 36 |
+
log_file = Path(log_dir) / f"metaphor_analyzer_{timestamp}.log"
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger("metaphor_analyzer")
|
| 39 |
+
logger.setLevel(logging.INFO)
|
| 40 |
+
|
| 41 |
+
# Clear any existing handlers
|
| 42 |
+
logger.handlers.clear()
|
| 43 |
+
|
| 44 |
+
# File handler
|
| 45 |
+
file_handler = logging.FileHandler(log_file)
|
| 46 |
+
file_handler.setLevel(logging.INFO)
|
| 47 |
+
file_formatter = logging.Formatter(
|
| 48 |
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 49 |
+
)
|
| 50 |
+
file_handler.setFormatter(file_formatter)
|
| 51 |
+
|
| 52 |
+
# Console handler
|
| 53 |
+
console_handler = logging.StreamHandler()
|
| 54 |
+
console_handler.setLevel(logging.INFO)
|
| 55 |
+
console_formatter = logging.Formatter("%(levelname)s - %(message)s")
|
| 56 |
+
console_handler.setFormatter(console_formatter)
|
| 57 |
+
|
| 58 |
+
logger.addHandler(file_handler)
|
| 59 |
+
logger.addHandler(console_handler)
|
| 60 |
+
|
| 61 |
+
return logger
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def extract_json_from_text(text: str) -> Optional[str]:
|
| 65 |
+
"""
|
| 66 |
+
Extract JSON from text that might contain markdown code blocks or prose.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
text: Raw text that might contain JSON
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Extracted JSON string or None if no JSON found
|
| 73 |
+
"""
|
| 74 |
+
# Try to find JSON in markdown code blocks
|
| 75 |
+
if "```json" in text:
|
| 76 |
+
start = text.find("```json") + 7
|
| 77 |
+
end = text.find("```", start)
|
| 78 |
+
if end > start:
|
| 79 |
+
return text[start:end].strip()
|
| 80 |
+
|
| 81 |
+
if "```" in text:
|
| 82 |
+
start = text.find("```") + 3
|
| 83 |
+
end = text.find("```", start)
|
| 84 |
+
if end > start:
|
| 85 |
+
potential_json = text[start:end].strip()
|
| 86 |
+
if potential_json.startswith("{"):
|
| 87 |
+
return potential_json
|
| 88 |
+
|
| 89 |
+
# Try to find raw JSON by looking for curly braces
|
| 90 |
+
start = text.find("{")
|
| 91 |
+
end = text.rfind("}")
|
| 92 |
+
if start >= 0 and end > start:
|
| 93 |
+
return text[start:end + 1].strip()
|
| 94 |
+
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def validate_schema(data: Dict[str, Any]) -> tuple[bool, Optional[str]]:
|
| 99 |
+
"""
|
| 100 |
+
Validate JSON data against the expected schema.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
data: Parsed JSON data to validate
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
Tuple of (is_valid, error_message)
|
| 107 |
+
"""
|
| 108 |
+
for key, expected_type in SCHEMA.items():
|
| 109 |
+
if key not in data:
|
| 110 |
+
return False, f"Missing required field: {key}"
|
| 111 |
+
|
| 112 |
+
if not isinstance(data[key], expected_type):
|
| 113 |
+
return False, f"Field '{key}' has wrong type. Expected {expected_type.__name__}, got {type(data[key]).__name__}"
|
| 114 |
+
|
| 115 |
+
# Additional validation for notation_details
|
| 116 |
+
if not data["notation_details"]:
|
| 117 |
+
return False, "Field 'notation_details' cannot be empty"
|
| 118 |
+
|
| 119 |
+
if not all(isinstance(d, str) for d in data["notation_details"]):
|
| 120 |
+
return False, "All items in 'notation_details' must be strings"
|
| 121 |
+
|
| 122 |
+
# Additional validation for instructional_metaphors
|
| 123 |
+
if not data["instructional_metaphors"]:
|
| 124 |
+
return False, "Field 'instructional_metaphors' cannot be empty"
|
| 125 |
+
|
| 126 |
+
if len(data["instructional_metaphors"]) != 3:
|
| 127 |
+
return False, f"Field 'instructional_metaphors' must contain exactly 3 items, got {len(data['instructional_metaphors'])}"
|
| 128 |
+
|
| 129 |
+
if not all(isinstance(m, str) for m in data["instructional_metaphors"]):
|
| 130 |
+
return False, "All items in 'instructional_metaphors' must be strings"
|
| 131 |
+
|
| 132 |
+
return True, None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def parse_and_validate_json(
|
| 136 |
+
response_text: str,
|
| 137 |
+
logger: Optional[logging.Logger] = None
|
| 138 |
+
) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
|
| 139 |
+
"""
|
| 140 |
+
Parse and validate JSON response from Claude.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
response_text: Raw response text from API
|
| 144 |
+
logger: Optional logger instance
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
Tuple of (parsed_data, error_message)
|
| 148 |
+
"""
|
| 149 |
+
if logger:
|
| 150 |
+
logger.info(f"Raw response: {response_text[:500]}...")
|
| 151 |
+
|
| 152 |
+
# Try to extract JSON
|
| 153 |
+
json_str = extract_json_from_text(response_text)
|
| 154 |
+
|
| 155 |
+
if not json_str:
|
| 156 |
+
# Maybe it's already pure JSON
|
| 157 |
+
json_str = response_text.strip()
|
| 158 |
+
|
| 159 |
+
# Try to parse
|
| 160 |
+
try:
|
| 161 |
+
data = json.loads(json_str)
|
| 162 |
+
except json.JSONDecodeError as e:
|
| 163 |
+
error = f"JSON parsing failed: {str(e)}"
|
| 164 |
+
if logger:
|
| 165 |
+
logger.error(error)
|
| 166 |
+
return None, error
|
| 167 |
+
|
| 168 |
+
# Validate schema
|
| 169 |
+
is_valid, error_msg = validate_schema(data)
|
| 170 |
+
|
| 171 |
+
if not is_valid:
|
| 172 |
+
if logger:
|
| 173 |
+
logger.error(f"Schema validation failed: {error_msg}")
|
| 174 |
+
return None, f"Schema validation failed: {error_msg}"
|
| 175 |
+
|
| 176 |
+
if logger:
|
| 177 |
+
logger.info(f"Successfully parsed and validated JSON: {json.dumps(data, indent=2)}")
|
| 178 |
+
|
| 179 |
+
return data, None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def save_analysis_log(
|
| 183 |
+
image_path: str,
|
| 184 |
+
raw_response: str,
|
| 185 |
+
parsed_data: Optional[Dict[str, Any]],
|
| 186 |
+
error: Optional[str],
|
| 187 |
+
log_dir: str = "./logs"
|
| 188 |
+
) -> None:
|
| 189 |
+
"""
|
| 190 |
+
Save detailed analysis log to file.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
image_path: Path to analyzed image
|
| 194 |
+
raw_response: Raw API response
|
| 195 |
+
parsed_data: Parsed JSON data (if successful)
|
| 196 |
+
error: Error message (if failed)
|
| 197 |
+
log_dir: Directory to store logs
|
| 198 |
+
"""
|
| 199 |
+
Path(log_dir).mkdir(exist_ok=True)
|
| 200 |
+
|
| 201 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 202 |
+
log_file = Path(log_dir) / f"analysis_{timestamp}.json"
|
| 203 |
+
|
| 204 |
+
log_entry = {
|
| 205 |
+
"timestamp": datetime.now().isoformat(),
|
| 206 |
+
"image_path": image_path,
|
| 207 |
+
"raw_response": raw_response,
|
| 208 |
+
"parsed_data": parsed_data,
|
| 209 |
+
"error": error,
|
| 210 |
+
"success": parsed_data is not None
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
with open(log_file, "w", encoding="utf-8") as f:
|
| 214 |
+
json.dump(log_entry, f, indent=2, ensure_ascii=False)
|