willie commited on
Commit
f5b3d19
·
1 Parent(s): 1a20172

Intitial commit

Browse files
Files changed (4) hide show
  1. DEPLOYMENT.md +189 -0
  2. app.py +370 -0
  3. requirements.txt +3 -0
  4. 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)