Gary Simmons
commited on
Commit
Β·
bc3f6a6
1
Parent(s):
23fcdda
create chess tools and refactor tests and tools via domains
Browse files- tools/__init__.py β __init__.py +10 -3
- app.py +5 -2
- libs/chess/chess_tools.py +241 -0
- libs/chess/test_chess_tools.py +0 -0
- {tests β libs/transcription}/test_transcription_tools.py +12 -9
- {tests β libs/transcription}/test_transcription_tools_standalone.py +10 -7
- {tools β libs/transcription}/transcription_tools.py +0 -0
- {tests β libs/youtube}/test_youtube_tools.py +1 -1
- {scripts β libs/youtube}/youtube_demo.py +0 -0
- {tools β libs/youtube}/youtube_tools.py +1 -1
- {tools β libs/youtube}/youtube_video_analyzer.py +4 -2
- requirements.txt +3 -1
tools/__init__.py β __init__.py
RENAMED
|
@@ -5,7 +5,14 @@ This package contains custom tools for the agent, including YouTube video analys
|
|
| 5 |
and audio transcription capabilities.
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
from .youtube_tools import analyze_youtube_video, get_youtube_video_info
|
| 9 |
-
from .transcription_tools import transcribe_audio
|
|
|
|
| 10 |
|
| 11 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
and audio transcription capabilities.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from libs.youtube.youtube_tools import analyze_youtube_video, get_youtube_video_info
|
| 9 |
+
from libs.transcription.transcription_tools import transcribe_audio
|
| 10 |
+
from libs.chess.chess_tools import analyze_chess_image, analyze_chess_position
|
| 11 |
|
| 12 |
+
__all__ = [
|
| 13 |
+
"analyze_youtube_video",
|
| 14 |
+
"get_youtube_video_info",
|
| 15 |
+
"transcribe_audio",
|
| 16 |
+
"analyze_chess_image",
|
| 17 |
+
"analyze_chess_position",
|
| 18 |
+
]
|
app.py
CHANGED
|
@@ -15,9 +15,10 @@ from smolagents import (
|
|
| 15 |
WikipediaSearchTool,
|
| 16 |
SpeechToTextTool,
|
| 17 |
LiteLLMModel,
|
| 18 |
-
tool,
|
| 19 |
)
|
| 20 |
-
from
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
# (Keep Constants as is)
|
|
@@ -192,6 +193,8 @@ class BasicAgent:
|
|
| 192 |
transcribe_audio,
|
| 193 |
analyze_youtube_video,
|
| 194 |
get_youtube_video_info,
|
|
|
|
|
|
|
| 195 |
],
|
| 196 |
model=model,
|
| 197 |
max_steps=20,
|
|
|
|
| 15 |
WikipediaSearchTool,
|
| 16 |
SpeechToTextTool,
|
| 17 |
LiteLLMModel,
|
|
|
|
| 18 |
)
|
| 19 |
+
from libs.chess.chess_tools import analyze_chess_image, analyze_chess_position
|
| 20 |
+
from libs.transcription.transcription_tools import transcribe_audio
|
| 21 |
+
from libs.youtube.youtube_tools import analyze_youtube_video, get_youtube_video_info
|
| 22 |
|
| 23 |
|
| 24 |
# (Keep Constants as is)
|
|
|
|
| 193 |
transcribe_audio,
|
| 194 |
analyze_youtube_video,
|
| 195 |
get_youtube_video_info,
|
| 196 |
+
analyze_chess_position,
|
| 197 |
+
analyze_chess_image
|
| 198 |
],
|
| 199 |
model=model,
|
| 200 |
max_steps=20,
|
libs/chess/chess_tools.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chess analysis tools for the Agents Course Final Assignment
|
| 3 |
+
|
| 4 |
+
Provides two tools for the agent:
|
| 5 |
+
- analyze_chess_position: analyze a chess position provided as FEN or PGN text
|
| 6 |
+
- analyze_chess_image: try to extract a FEN from an image (best-effort) and analyze it
|
| 7 |
+
|
| 8 |
+
The analysis uses python-chess for parsing and a small heuristic evaluation
|
| 9 |
+
so the tools do not require a native engine like Stockfish. If pytesseract is
|
| 10 |
+
available, the image tool will attempt OCR to find a FEN string inside the image.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from smolagents import tool
|
| 14 |
+
import io
|
| 15 |
+
import json
|
| 16 |
+
import tempfile
|
| 17 |
+
import re
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
import chess
|
| 21 |
+
import chess.pgn
|
| 22 |
+
except Exception:
|
| 23 |
+
chess = None
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
import cv2
|
| 27 |
+
import numpy as np
|
| 28 |
+
except Exception:
|
| 29 |
+
cv2 = None
|
| 30 |
+
np = None
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
from PIL import Image
|
| 34 |
+
except Exception:
|
| 35 |
+
Image = None
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
import pytesseract
|
| 39 |
+
except Exception:
|
| 40 |
+
pytesseract = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
PIECE_VALUES = (
|
| 44 |
+
{
|
| 45 |
+
chess.PAWN if chess else "p": 1,
|
| 46 |
+
chess.KNIGHT if chess else "n": 3,
|
| 47 |
+
chess.BISHOP if chess else "b": 3,
|
| 48 |
+
chess.ROOK if chess else "r": 5,
|
| 49 |
+
chess.QUEEN if chess else "q": 9,
|
| 50 |
+
chess.KING if chess else "k": 0,
|
| 51 |
+
}
|
| 52 |
+
if chess
|
| 53 |
+
else {}
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _simple_material_evaluation(board):
|
| 58 |
+
"""Return material balance from White's perspective and a short breakdown."""
|
| 59 |
+
if chess is None:
|
| 60 |
+
raise RuntimeError("python-chess is required for position analysis")
|
| 61 |
+
|
| 62 |
+
values = {
|
| 63 |
+
chess.PAWN: 1,
|
| 64 |
+
chess.KNIGHT: 3,
|
| 65 |
+
chess.BISHOP: 3,
|
| 66 |
+
chess.ROOK: 5,
|
| 67 |
+
chess.QUEEN: 9,
|
| 68 |
+
chess.KING: 0,
|
| 69 |
+
}
|
| 70 |
+
white = 0
|
| 71 |
+
black = 0
|
| 72 |
+
counts = {}
|
| 73 |
+
for piece_type in values:
|
| 74 |
+
w = len(board.pieces(piece_type, chess.WHITE))
|
| 75 |
+
b = len(board.pieces(piece_type, chess.BLACK))
|
| 76 |
+
counts[piece_type] = (w, b)
|
| 77 |
+
white += w * values[piece_type]
|
| 78 |
+
black += b * values[piece_type]
|
| 79 |
+
|
| 80 |
+
balance = white - black
|
| 81 |
+
breakdown = {
|
| 82 |
+
chess.piece_name(pt): {
|
| 83 |
+
"white": counts[pt][0],
|
| 84 |
+
"black": counts[pt][1],
|
| 85 |
+
"value": values[pt],
|
| 86 |
+
}
|
| 87 |
+
for pt in counts
|
| 88 |
+
}
|
| 89 |
+
return balance, breakdown
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _format_moves(moves, max_moves=8):
|
| 93 |
+
return ", ".join([m for m in moves[:max_moves]])
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@tool
|
| 97 |
+
def analyze_chess_position(position_text: str, max_moves: int = 8) -> str:
|
| 98 |
+
"""
|
| 99 |
+
Analyze a chess position provided as FEN or PGN (single-game). Returns a JSON string
|
| 100 |
+
with basic diagnostics: legality, side to move, check/checkmate/stalemate, material
|
| 101 |
+
balance, and a short list of legal moves.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
position_text: A FEN string, or a PGN string containing at least one game.
|
| 105 |
+
max_moves: How many top legal moves to list in the output (default 8).
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
JSON string with analysis results.
|
| 109 |
+
"""
|
| 110 |
+
try:
|
| 111 |
+
if chess is None:
|
| 112 |
+
return json.dumps(
|
| 113 |
+
{"status": "error", "error": "python-chess is not installed"}, indent=2
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
position_text = position_text.strip()
|
| 117 |
+
|
| 118 |
+
board = None
|
| 119 |
+
|
| 120 |
+
# Detect FEN: a FEN contains 6 space-separated fields (piece placement + side to move + ...)
|
| 121 |
+
if len(position_text.split()) >= 2 and "/" in position_text.split()[0]:
|
| 122 |
+
# Likely a FEN
|
| 123 |
+
try:
|
| 124 |
+
board = chess.Board(position_text.splitlines()[0])
|
| 125 |
+
except Exception:
|
| 126 |
+
# try using the full string
|
| 127 |
+
board = chess.Board(position_text)
|
| 128 |
+
else:
|
| 129 |
+
# Try PGN parsing (look for move text or headers)
|
| 130 |
+
try:
|
| 131 |
+
pgn_io = io.StringIO(position_text)
|
| 132 |
+
game = chess.pgn.read_game(pgn_io)
|
| 133 |
+
if game is None:
|
| 134 |
+
# Maybe the input is FEN without fields
|
| 135 |
+
board = chess.Board(position_text)
|
| 136 |
+
else:
|
| 137 |
+
# get the final position after the game
|
| 138 |
+
node = game
|
| 139 |
+
while node.variations:
|
| 140 |
+
node = node.variations[0]
|
| 141 |
+
board = node.board()
|
| 142 |
+
except Exception:
|
| 143 |
+
# Fallback: try to construct from the text directly
|
| 144 |
+
try:
|
| 145 |
+
board = chess.Board(position_text)
|
| 146 |
+
except Exception as e:
|
| 147 |
+
return json.dumps(
|
| 148 |
+
{"status": "error", "error": f"Could not parse FEN/PGN: {e}"},
|
| 149 |
+
indent=2,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Now we have a board
|
| 153 |
+
legal_moves = [board.san(m) for m in board.legal_moves]
|
| 154 |
+
balance, breakdown = _simple_material_evaluation(board)
|
| 155 |
+
|
| 156 |
+
result = {
|
| 157 |
+
"status": "success",
|
| 158 |
+
"fen": board.fen(),
|
| 159 |
+
"turn": "white" if board.turn == chess.WHITE else "black",
|
| 160 |
+
"is_check": board.is_check(),
|
| 161 |
+
"is_checkmate": board.is_checkmate(),
|
| 162 |
+
"is_stalemate": board.is_stalemate(),
|
| 163 |
+
"is_insufficient_material": board.is_insufficient_material(),
|
| 164 |
+
"material_balance_white_minus_black": balance,
|
| 165 |
+
"material_breakdown": breakdown,
|
| 166 |
+
"legal_moves_count": (
|
| 167 |
+
board.legal_moves.count()
|
| 168 |
+
if hasattr(board.legal_moves, "count")
|
| 169 |
+
else len(list(board.legal_moves))
|
| 170 |
+
),
|
| 171 |
+
"legal_moves_sample": _format_moves(legal_moves, max_moves=max_moves),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
return json.dumps(result, indent=2)
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
return json.dumps({"status": "error", "error": str(e)}, indent=2)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@tool
|
| 181 |
+
def analyze_chess_image(image_bytes: bytes) -> str:
|
| 182 |
+
"""
|
| 183 |
+
Best-effort: try to extract a FEN string from an image using OCR (if available),
|
| 184 |
+
and then analyze the position. If OCR is unavailable or fails, save the image to a
|
| 185 |
+
temporary file and return a helpful message describing how to use the `analyze_chess_position` tool.
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
image_bytes: Raw image bytes (PNG/JPEG/etc.)
|
| 189 |
+
|
| 190 |
+
Returns:
|
| 191 |
+
JSON string. If a FEN was found and parsed, returns the same output as `analyze_chess_position`.
|
| 192 |
+
Otherwise returns a helpful error/message and a temporary path where the image was saved.
|
| 193 |
+
"""
|
| 194 |
+
try:
|
| 195 |
+
# First try OCR if pytesseract is available
|
| 196 |
+
if pytesseract and Image:
|
| 197 |
+
try:
|
| 198 |
+
img = Image.open(io.BytesIO(image_bytes)).convert("L")
|
| 199 |
+
text = pytesseract.image_to_string(img)
|
| 200 |
+
# Search for a FEN-like pattern: 8 ranks separated by '/'
|
| 201 |
+
fen_match = re.search(
|
| 202 |
+
r"([prnbqkPRNBQK1-8]+\/){7}[prnbqkPRNBQK1-8]+(?:\s[bBqQrRkK-]+.*)?",
|
| 203 |
+
text,
|
| 204 |
+
)
|
| 205 |
+
if fen_match:
|
| 206 |
+
fen_candidate = fen_match.group(0).strip()
|
| 207 |
+
# Try to analyze
|
| 208 |
+
return analyze_chess_position(fen_candidate)
|
| 209 |
+
# If no fen, maybe the OCR returns lines; attempt to find FEN in any line
|
| 210 |
+
for line in text.splitlines():
|
| 211 |
+
line = line.strip()
|
| 212 |
+
if not line:
|
| 213 |
+
continue
|
| 214 |
+
if "/" in line and len(line) >= 17:
|
| 215 |
+
# try parse
|
| 216 |
+
try:
|
| 217 |
+
return analyze_chess_position(line)
|
| 218 |
+
except Exception:
|
| 219 |
+
continue
|
| 220 |
+
# No FEN found via OCR
|
| 221 |
+
except Exception:
|
| 222 |
+
# OCR step failed; we'll fall back to saving image
|
| 223 |
+
pass
|
| 224 |
+
|
| 225 |
+
# Save image to temp file so a human (or another automated step) can inspect it
|
| 226 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
|
| 227 |
+
tmp.write(image_bytes)
|
| 228 |
+
tmp.flush()
|
| 229 |
+
tmp.close()
|
| 230 |
+
|
| 231 |
+
message = {
|
| 232 |
+
"status": "no_fen_found",
|
| 233 |
+
"message": "Could not automatically extract a FEN from the provided image."
|
| 234 |
+
" If the image includes a FEN string, install pytesseract and tesseract-ocr, or provide the FEN/PGN text directly",
|
| 235 |
+
"saved_image_path": tmp.name,
|
| 236 |
+
"how_to_use": "Call analyze_chess_position(fen_or_pgn) with a FEN string or PGN to get an analysis.",
|
| 237 |
+
}
|
| 238 |
+
return json.dumps(message, indent=2)
|
| 239 |
+
|
| 240 |
+
except Exception as e:
|
| 241 |
+
return json.dumps({"status": "error", "error": str(e)}, indent=2)
|
libs/chess/test_chess_tools.py
ADDED
|
File without changes
|
{tests β libs/transcription}/test_transcription_tools.py
RENAMED
|
@@ -13,11 +13,14 @@ import struct
|
|
| 13 |
import unittest
|
| 14 |
from unittest.mock import Mock, patch, MagicMock
|
| 15 |
|
| 16 |
-
# Add the
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
# Import the transcription tool directly to avoid YouTube tool dependencies
|
| 20 |
-
from
|
| 21 |
|
| 22 |
|
| 23 |
class TestTranscriptionTools(unittest.TestCase):
|
|
@@ -55,7 +58,7 @@ class TestTranscriptionTools(unittest.TestCase):
|
|
| 55 |
|
| 56 |
return wav_buffer.getvalue()
|
| 57 |
|
| 58 |
-
@patch("
|
| 59 |
def test_transcribe_audio_success(self, mock_speech_tool_class):
|
| 60 |
"""Test successful audio transcription."""
|
| 61 |
# Setup mock
|
|
@@ -73,7 +76,7 @@ class TestTranscriptionTools(unittest.TestCase):
|
|
| 73 |
mock_speech_tool_class.assert_called_once()
|
| 74 |
mock_speech_tool.transcribe.assert_called_once_with(self.sample_audio_bytes)
|
| 75 |
|
| 76 |
-
@patch("
|
| 77 |
def test_transcribe_audio_empty_bytes(self, mock_speech_tool_class):
|
| 78 |
"""Test transcription with empty audio bytes."""
|
| 79 |
# Setup mock
|
|
@@ -88,7 +91,7 @@ class TestTranscriptionTools(unittest.TestCase):
|
|
| 88 |
self.assertEqual(result, "")
|
| 89 |
mock_speech_tool.transcribe.assert_called_once_with(b"")
|
| 90 |
|
| 91 |
-
@patch("
|
| 92 |
def test_transcribe_audio_tool_exception(self, mock_speech_tool_class):
|
| 93 |
"""Test transcription when SpeechToTextTool raises an exception."""
|
| 94 |
# Setup mock to raise exception
|
|
@@ -105,7 +108,7 @@ class TestTranscriptionTools(unittest.TestCase):
|
|
| 105 |
self.assertIn("Failed to transcribe audio", str(context.exception))
|
| 106 |
self.assertIn("Transcription service unavailable", str(context.exception))
|
| 107 |
|
| 108 |
-
@patch("
|
| 109 |
def test_transcribe_audio_invalid_format(self, mock_speech_tool_class):
|
| 110 |
"""Test transcription with invalid audio format."""
|
| 111 |
# Setup mock to raise exception for invalid format
|
|
@@ -140,7 +143,7 @@ class TestTranscriptionTools(unittest.TestCase):
|
|
| 140 |
# The function should at least be callable
|
| 141 |
self.assertTrue(callable(transcribe_audio))
|
| 142 |
|
| 143 |
-
@patch("
|
| 144 |
def test_transcribe_audio_with_various_formats_description(
|
| 145 |
self, mock_speech_tool_class
|
| 146 |
):
|
|
@@ -170,7 +173,7 @@ def test_basic_functionality():
|
|
| 170 |
|
| 171 |
try:
|
| 172 |
# Import the function to make sure it exists and imports work
|
| 173 |
-
from
|
| 174 |
|
| 175 |
print("β
Successfully imported transcribe_audio function")
|
| 176 |
|
|
|
|
| 13 |
import unittest
|
| 14 |
from unittest.mock import Mock, patch, MagicMock
|
| 15 |
|
| 16 |
+
# Add the project root to the path so 'libs' can be imported (was appending 'libs' itself)
|
| 17 |
+
# This uses the directory three levels up from this test file (project root).
|
| 18 |
+
sys.path.append(
|
| 19 |
+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 20 |
+
)
|
| 21 |
|
| 22 |
# Import the transcription tool directly to avoid YouTube tool dependencies
|
| 23 |
+
from libs.transcription.transcription_tools import transcribe_audio
|
| 24 |
|
| 25 |
|
| 26 |
class TestTranscriptionTools(unittest.TestCase):
|
|
|
|
| 58 |
|
| 59 |
return wav_buffer.getvalue()
|
| 60 |
|
| 61 |
+
@patch("libs.transcription.transcription_tools.SpeechToTextTool")
|
| 62 |
def test_transcribe_audio_success(self, mock_speech_tool_class):
|
| 63 |
"""Test successful audio transcription."""
|
| 64 |
# Setup mock
|
|
|
|
| 76 |
mock_speech_tool_class.assert_called_once()
|
| 77 |
mock_speech_tool.transcribe.assert_called_once_with(self.sample_audio_bytes)
|
| 78 |
|
| 79 |
+
@patch("libs.transcription.transcription_tools.SpeechToTextTool")
|
| 80 |
def test_transcribe_audio_empty_bytes(self, mock_speech_tool_class):
|
| 81 |
"""Test transcription with empty audio bytes."""
|
| 82 |
# Setup mock
|
|
|
|
| 91 |
self.assertEqual(result, "")
|
| 92 |
mock_speech_tool.transcribe.assert_called_once_with(b"")
|
| 93 |
|
| 94 |
+
@patch("libs.transcription.transcription_tools.SpeechToTextTool")
|
| 95 |
def test_transcribe_audio_tool_exception(self, mock_speech_tool_class):
|
| 96 |
"""Test transcription when SpeechToTextTool raises an exception."""
|
| 97 |
# Setup mock to raise exception
|
|
|
|
| 108 |
self.assertIn("Failed to transcribe audio", str(context.exception))
|
| 109 |
self.assertIn("Transcription service unavailable", str(context.exception))
|
| 110 |
|
| 111 |
+
@patch("libs.transcription.transcription_tools.SpeechToTextTool")
|
| 112 |
def test_transcribe_audio_invalid_format(self, mock_speech_tool_class):
|
| 113 |
"""Test transcription with invalid audio format."""
|
| 114 |
# Setup mock to raise exception for invalid format
|
|
|
|
| 143 |
# The function should at least be callable
|
| 144 |
self.assertTrue(callable(transcribe_audio))
|
| 145 |
|
| 146 |
+
@patch("libs.transcription.transcription_tools.SpeechToTextTool")
|
| 147 |
def test_transcribe_audio_with_various_formats_description(
|
| 148 |
self, mock_speech_tool_class
|
| 149 |
):
|
|
|
|
| 173 |
|
| 174 |
try:
|
| 175 |
# Import the function to make sure it exists and imports work
|
| 176 |
+
from libs.transcription.transcription_tools import transcribe_audio
|
| 177 |
|
| 178 |
print("β
Successfully imported transcribe_audio function")
|
| 179 |
|
{tests β libs/transcription}/test_transcription_tools_standalone.py
RENAMED
|
@@ -14,8 +14,11 @@ import struct
|
|
| 14 |
import unittest
|
| 15 |
from unittest.mock import Mock, patch
|
| 16 |
|
| 17 |
-
# Add the
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def test_basic_functionality():
|
|
@@ -24,7 +27,7 @@ def test_basic_functionality():
|
|
| 24 |
|
| 25 |
try:
|
| 26 |
# Import the function directly to make sure it exists and imports work
|
| 27 |
-
from
|
| 28 |
|
| 29 |
print("β
Successfully imported transcribe_audio function")
|
| 30 |
|
|
@@ -56,7 +59,7 @@ def test_transcribe_with_mock():
|
|
| 56 |
|
| 57 |
try:
|
| 58 |
# Import the function
|
| 59 |
-
from
|
| 60 |
|
| 61 |
# Create sample audio bytes (simple WAV file structure)
|
| 62 |
sample_rate = 44100
|
|
@@ -81,7 +84,7 @@ def test_transcribe_with_mock():
|
|
| 81 |
|
| 82 |
# Mock the SpeechToTextTool
|
| 83 |
with patch(
|
| 84 |
-
"
|
| 85 |
) as mock_speech_tool_class:
|
| 86 |
mock_speech_tool = Mock()
|
| 87 |
mock_speech_tool.transcribe.return_value = (
|
|
@@ -115,11 +118,11 @@ def test_error_handling():
|
|
| 115 |
print("Testing error handling...")
|
| 116 |
|
| 117 |
try:
|
| 118 |
-
from
|
| 119 |
|
| 120 |
# Mock the SpeechToTextTool to raise an exception
|
| 121 |
with patch(
|
| 122 |
-
"
|
| 123 |
) as mock_speech_tool_class:
|
| 124 |
mock_speech_tool = Mock()
|
| 125 |
mock_speech_tool.transcribe.side_effect = Exception(
|
|
|
|
| 14 |
import unittest
|
| 15 |
from unittest.mock import Mock, patch
|
| 16 |
|
| 17 |
+
# Add the project root to the path so 'libs' can be imported (was appending 'libs' itself)
|
| 18 |
+
# This uses the directory three levels up from this test file (project root).
|
| 19 |
+
sys.path.append(
|
| 20 |
+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 21 |
+
)
|
| 22 |
|
| 23 |
|
| 24 |
def test_basic_functionality():
|
|
|
|
| 27 |
|
| 28 |
try:
|
| 29 |
# Import the function directly to make sure it exists and imports work
|
| 30 |
+
from libs.transcription.transcription_tools import transcribe_audio
|
| 31 |
|
| 32 |
print("β
Successfully imported transcribe_audio function")
|
| 33 |
|
|
|
|
| 59 |
|
| 60 |
try:
|
| 61 |
# Import the function
|
| 62 |
+
from libs.transcription.transcription_tools import transcribe_audio
|
| 63 |
|
| 64 |
# Create sample audio bytes (simple WAV file structure)
|
| 65 |
sample_rate = 44100
|
|
|
|
| 84 |
|
| 85 |
# Mock the SpeechToTextTool
|
| 86 |
with patch(
|
| 87 |
+
"libs.transcription.transcription_tools.SpeechToTextTool"
|
| 88 |
) as mock_speech_tool_class:
|
| 89 |
mock_speech_tool = Mock()
|
| 90 |
mock_speech_tool.transcribe.return_value = (
|
|
|
|
| 118 |
print("Testing error handling...")
|
| 119 |
|
| 120 |
try:
|
| 121 |
+
from libs.transcription.transcription_tools import transcribe_audio
|
| 122 |
|
| 123 |
# Mock the SpeechToTextTool to raise an exception
|
| 124 |
with patch(
|
| 125 |
+
"libs.transcription.transcription_tools.SpeechToTextTool"
|
| 126 |
) as mock_speech_tool_class:
|
| 127 |
mock_speech_tool = Mock()
|
| 128 |
mock_speech_tool.transcribe.side_effect = Exception(
|
{tools β libs/transcription}/transcription_tools.py
RENAMED
|
File without changes
|
{tests β libs/youtube}/test_youtube_tools.py
RENAMED
|
@@ -10,7 +10,7 @@ import os
|
|
| 10 |
|
| 11 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
|
| 13 |
-
from
|
| 14 |
|
| 15 |
|
| 16 |
def test_video_info():
|
|
|
|
| 10 |
|
| 11 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
|
| 13 |
+
from libs.youtube.youtube_tools import get_youtube_video_info, analyze_youtube_video
|
| 14 |
|
| 15 |
|
| 16 |
def test_video_info():
|
{scripts β libs/youtube}/youtube_demo.py
RENAMED
|
File without changes
|
{tools β libs/youtube}/youtube_tools.py
RENAMED
|
@@ -7,7 +7,7 @@ by extracting frames at intervals and analyzing their content.
|
|
| 7 |
|
| 8 |
import json
|
| 9 |
from smolagents import tool
|
| 10 |
-
from
|
| 11 |
analyze_youtube_video_frames,
|
| 12 |
get_video_metadata,
|
| 13 |
)
|
|
|
|
| 7 |
|
| 8 |
import json
|
| 9 |
from smolagents import tool
|
| 10 |
+
from libs.youtube.youtube_video_analyzer import (
|
| 11 |
analyze_youtube_video_frames,
|
| 12 |
get_video_metadata,
|
| 13 |
)
|
{tools β libs/youtube}/youtube_video_analyzer.py
RENAMED
|
@@ -58,7 +58,7 @@ def extract_video_frames(
|
|
| 58 |
# Find the downloaded video file
|
| 59 |
video_files = list(Path(temp_dir).glob("video.*"))
|
| 60 |
if not video_files:
|
| 61 |
-
raise Exception("No video file found after download")
|
| 62 |
|
| 63 |
actual_video_path = str(video_files[0])
|
| 64 |
|
|
@@ -66,13 +66,15 @@ def extract_video_frames(
|
|
| 66 |
cap = cv2.VideoCapture(actual_video_path)
|
| 67 |
|
| 68 |
if not cap.isOpened():
|
| 69 |
-
raise Exception("Could not open video file")
|
| 70 |
|
| 71 |
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 72 |
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 73 |
duration = total_frames / fps if fps > 0 else 0
|
| 74 |
|
| 75 |
frame_interval = int(fps * interval_seconds)
|
|
|
|
|
|
|
| 76 |
frame_count = 0
|
| 77 |
extracted_count = 0
|
| 78 |
|
|
|
|
| 58 |
# Find the downloaded video file
|
| 59 |
video_files = list(Path(temp_dir).glob("video.*"))
|
| 60 |
if not video_files:
|
| 61 |
+
raise Exception("No video file found after download. The download may have failed or the video may be unavailable.")
|
| 62 |
|
| 63 |
actual_video_path = str(video_files[0])
|
| 64 |
|
|
|
|
| 66 |
cap = cv2.VideoCapture(actual_video_path)
|
| 67 |
|
| 68 |
if not cap.isOpened():
|
| 69 |
+
raise Exception(f"Could not open video file: {actual_video_path}")
|
| 70 |
|
| 71 |
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 72 |
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 73 |
duration = total_frames / fps if fps > 0 else 0
|
| 74 |
|
| 75 |
frame_interval = int(fps * interval_seconds)
|
| 76 |
+
if frame_interval == 0:
|
| 77 |
+
frame_interval = 1
|
| 78 |
frame_count = 0
|
| 79 |
extracted_count = 0
|
| 80 |
|
requirements.txt
CHANGED
|
@@ -14,4 +14,6 @@ yt-dlp
|
|
| 14 |
openai-whisper
|
| 15 |
torch
|
| 16 |
transformers
|
| 17 |
-
opencv-python
|
|
|
|
|
|
|
|
|
| 14 |
openai-whisper
|
| 15 |
torch
|
| 16 |
transformers
|
| 17 |
+
opencv-python
|
| 18 |
+
python-chess>=1.9.0
|
| 19 |
+
pytesseract
|