Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 70 additions & 61 deletions backend/app.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,74 @@
import builtins
import io
import logging
import os
import sys
import time
from typing import Any, Dict, Optional, Union

from flask import Flask, Response, jsonify, request, send_file, session
from flask_cors import CORS
from flask_socketio import SocketIO
import eventlet

eventlet.monkey_patch()

from flask import Flask, Response, jsonify, request, send_file, session # noqa: E402
from flask_cors import CORS # noqa: E402
from flask_socketio import SocketIO # noqa: E402

project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if project_root not in sys.path:
sys.path.insert(0, project_root)


original_print = builtins.print
original_stdout = sys.stdout

# Buffer to store messages until socketio is ready
log_buffer = []
_LOG_BUFFER: list[dict[str, Any]] = []
_MAX_LOG_BUFFER_LENGTH = 500


class WebSocketCapture(io.StringIO):
def write(self, text: str) -> int:
# Also write to original stdout
original_stdout.write(text)
# Store for WebSocket emission
if text.strip(): # Only non-empty messages
log_buffer.append(text.strip())
return len(text)
def _push_log(message: str, level: str = "info") -> None:
message = message.strip()
if not message:
return
payload = {
"message": message,
"level": level,
"timestamp": time.time(),
}
_LOG_BUFFER.append(payload)
if len(_LOG_BUFFER) > _MAX_LOG_BUFFER_LENGTH:
del _LOG_BUFFER[0]
try:
if "socketio" in globals():
socketio.emit("log", payload)
except Exception:
pass


class SocketIOLogHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
message = self.format(record)
except Exception:
message = record.getMessage()
_push_log(message, record.levelname.lower())


def flush_log_buffer() -> None:
global _LOG_BUFFER
if not _LOG_BUFFER:
return
try:
for payload in _LOG_BUFFER:
socketio.emit("log", payload)
except Exception:
return
finally:
_LOG_BUFFER = []


def websocket_print(*args: Any, **kwargs: Any) -> None:
# Call original print
original_print(*args, **kwargs)
# Also emit via WebSocket in real-time
message = " ".join(str(arg) for arg in args)
if message.strip():
emit_log_realtime(message.strip())
_push_log(message)
original_print(*args, **kwargs)


# Override print globally before importing tiny_scientist modules
Expand All @@ -53,41 +86,6 @@ def websocket_print(*args: Any, **kwargs: Any) -> None:
pass


# Create a function to emit buffered logs when socketio is ready
def emit_buffered_logs() -> None:
global log_buffer
try:
for message in log_buffer:
socketio.emit(
"log",
{
"message": message,
"level": "info",
"timestamp": __import__("time").time(),
},
)
log_buffer = [] # Clear buffer after emitting
except Exception:
pass


# Create a function to emit logs in real-time
def emit_log_realtime(message: str, level: str = "info") -> None:
try:
# Check if socketio is available
if "socketio" in globals():
socketio.emit(
"log",
{
"message": message,
"level": level,
"timestamp": __import__("time").time(),
},
)
except Exception:
pass


from tiny_scientist.budget_checker import BudgetChecker # noqa: E402
from tiny_scientist.coder import Coder # noqa: E402
from tiny_scientist.reviewer import Reviewer # noqa: E402
Expand Down Expand Up @@ -129,7 +127,15 @@ def patch_module_print() -> None:
"http://localhost:3000",
],
)
socketio = SocketIO(app, cors_allowed_origins="*")
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="eventlet")
root_logger = logging.getLogger()
if not any(isinstance(handler, SocketIOLogHandler) for handler in root_logger.handlers):
socketio_handler = SocketIOLogHandler()
socketio_handler.setLevel(logging.INFO)
socketio_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(socketio_handler)
if root_logger.level > logging.INFO:
root_logger.setLevel(logging.INFO)

# Print override is now active
print("πŸš€ Backend server starting with WebSocket logging enabled!")
Expand Down Expand Up @@ -245,7 +251,7 @@ def configure() -> Union[Response, tuple[Response, int]]:
@app.route("/api/generate-initial", methods=["POST"])
def generate_initial() -> Union[Response, tuple[Response, int]]:
"""Generate initial ideas from an intent (handleAnalysisIntentSubmit)"""
emit_buffered_logs() # Emit any buffered logs from module initialization
flush_log_buffer() # Emit any buffered logs from module initialization
data = request.json
if data is None:
return jsonify({"error": "No JSON data provided"}), 400
Expand Down Expand Up @@ -523,7 +529,7 @@ def format_idea_content(idea: Union[Dict[str, Any], str]) -> str:
@app.route("/api/code", methods=["POST"])
def generate_code() -> Union[Response, tuple[Response, int]]:
"""Generate code synchronously and return when complete"""
emit_buffered_logs() # Emit any buffered logs
flush_log_buffer() # Emit any buffered logs
global coder

if coder is None:
Expand Down Expand Up @@ -624,9 +630,8 @@ def generate_paper() -> Union[Response, tuple[Response, int]]:
experiment_dir = data.get("experiment_dir", None)

s2_api_key = data.get("s2_api_key", None)

if not s2_api_key:
return jsonify({"error": "Semantic Scholar API key is required"}), 400
if isinstance(s2_api_key, str):
s2_api_key = s2_api_key.strip() or None

if not idea_data:
print("ERROR: No idea provided in request")
Expand All @@ -653,6 +658,10 @@ def generate_paper() -> Union[Response, tuple[Response, int]]:
),
)
print(f"Writer initialized for this request with model: {writer.model}")
if not s2_api_key:
print(
"Proceeding without Semantic Scholar API key; using fallback sources."
)

# Extract the original idea data
if isinstance(idea_data, dict) and "originalData" in idea_data:
Expand Down
76 changes: 76 additions & 0 deletions backend/tests/test_coder_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import json
import os
from pathlib import Path

import pytest

from backend.app import app


@pytest.fixture(scope="module")
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client


def _load_demo_idea() -> dict:
idea_path = Path(__file__).resolve().parents[2] / "demo_test" / "idea.json"
if not idea_path.exists():
raise FileNotFoundError(f"Idea file not found: {idea_path}")
return json.loads(idea_path.read_text())


def _configure_backend(client) -> None:
model = "gpt-4o"
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
pytest.skip("OPENAI_API_KEY not set; skipping coder integration test")

response = client.post(
"/api/configure",
json={
"model": model,
"api_key": api_key,
"budget": 10.0,
"budget_preference": "balanced",
},
)
assert response.status_code == 200, response.get_data(as_text=True)


def test_coder_with_demo_idea(client):
"""
Integration test: run backend /api/code with the demo idea.
Ensures coder executes and produces experiment outputs.
"""
_configure_backend(client)

idea_payload = _load_demo_idea()
response = client.post(
"/api/code",
json={"idea": {"originalData": idea_payload}},
)

assert response.status_code == 200, response.get_data(as_text=True)

data = response.get_json()
assert data is not None, "No JSON body returned"
assert data.get("success") is True, data

experiment_dir = data.get("experiment_dir")
assert experiment_dir, data

generated_base = Path(__file__).resolve().parents[2] / "generated"
abs_experiment_dir = generated_base / experiment_dir
assert abs_experiment_dir.exists(), f"Experiment dir missing: {abs_experiment_dir}"

expected_files = {
"experiment.py",
"notes.txt",
"experiment_results.txt",
}
missing = [
name for name in expected_files if not (abs_experiment_dir / name).exists()
]
assert not missing, f"Missing files in {abs_experiment_dir}: {missing}"
1 change: 0 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"name": "hypo-eval",
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:5000",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@testing-library/jest-dom": "^5.17.0",
Expand Down
39 changes: 35 additions & 4 deletions frontend/src/components/LogDisplay.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { io } from 'socket.io-client';

const resolveSocketBaseURL = () => {
if (typeof window === 'undefined') {
return 'http://localhost:5000';
}

const explicitBase = process.env.REACT_APP_SOCKET_BASE_URL;
if (explicitBase) {
return explicitBase;
}

const { protocol, hostname, port } = window.location;

// When running the dev server (port 3000) we need to talk to the backend port.
if (port === '3000') {
const backendPort = process.env.REACT_APP_SOCKET_PORT || '5000';
return `${protocol}//${hostname}:${backendPort}`;
}

return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
};

const SOCKET_PATH = process.env.REACT_APP_SOCKET_PATH || '/socket.io';

const LogDisplay = ({ isVisible, onToggle }) => {
const [logs, setLogs] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef(null);
const logsEndRef = useRef(null);
const socketUrl = useMemo(resolveSocketBaseURL, []);

useEffect(() => {
// Always maintain socket connection when component mounts
socketRef.current = io('http://localhost:5000');
socketRef.current = io(socketUrl, {
path: SOCKET_PATH,
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 2000,
reconnectionDelayMax: 6000,
timeout: 10000,
});

socketRef.current.on('connect', () => {
setIsConnected(true);
Expand All @@ -26,8 +56,9 @@ const LogDisplay = ({ isVisible, onToggle }) => {

return () => {
socketRef.current?.disconnect();
socketRef.current = null;
};
}, []);
}, [socketUrl]);

useEffect(() => {
// Auto-scroll to bottom when new logs arrive
Expand Down
Loading
Loading