diff --git a/backend/server.py b/backend/server.py index 31c3d443..79fd8103 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,27 +1,24 @@ -from pydantic import BaseModel, Field, ConfigDict, AfterValidator -import os +import io import json - -from typing import List, Dict, Optional, Annotated, Literal -from pathlib import Path +import logging +import os +import zipfile from datetime import datetime +from pathlib import Path +from typing import Annotated, Dict, List, Literal, Optional +import nlp import uvicorn -import asyncio - -from fastapi import FastAPI, BackgroundTasks +from dotenv import load_dotenv +from fastapi import BackgroundTasks, Body, FastAPI +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse - +from pydantic import AfterValidator, BaseModel, ConfigDict from sse_starlette.sse import EventSourceResponse -from pydantic import BaseModel, ConfigDict -from dotenv import load_dotenv - -import nlp - -import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -35,12 +32,10 @@ DEBUG = os.getenv("DEBUG") or False PORT = int(os.getenv("PORT") or 8000) -# FIXME: Use auth0 instead +# The log secret is stored in .env file for local development. LOG_SECRET = os.getenv("LOG_SECRET", "").strip() print(f"Log secret: {LOG_SECRET!r}") -# Flag for whether to include any document text in the logs. -# In the future we'll enable this for developers and study participants who have consented. def should_log(username: str) -> bool: """ @@ -116,6 +111,7 @@ class ReflectionLog(Log): origins = ["*"] + app.add_middleware( CORSMiddleware, allow_origins=origins, @@ -125,6 +121,13 @@ class ReflectionLog(Log): ) +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + print(f"The client sent invalid data!: {exc}") + return await request_validation_exception_handler(request, exc) + + + # Routes @app.post("/api/generation") async def generation(payload: GenerationRequestPayload, background_tasks: BackgroundTasks) -> nlp.GenerationResult: @@ -244,40 +247,75 @@ async def ping() -> PingResponse: return PingResponse(timestamp=datetime.now()) -# Show all server logs -@app.get("/api/logs") -async def logs(secret: str): - async def log_generator(): - assert LOG_SECRET != "", "Logging secret not set." - if secret != LOG_SECRET: - yield json.dumps({"error": "Invalid secret."}) - return - - log_positions = {} - - while True: - updates = [] - log_files = { - participant.stem: participant for participant in LOG_PATH.glob("*.jsonl")} - - for username, log_file in log_files.items(): - with open(log_file, "r") as file: - file.seek(log_positions.get(username, 0)) - new_lines = file.readlines() - - if new_lines: - updates.append({ - "username": username, - "logs": [json.loads(line) for line in new_lines], - }) - log_positions[username] = file.tell() +# Log viewer endpoint +class LogsPollRequest(BaseModel): + log_positions: Dict[str, int] + secret: str - if updates: - yield json.dumps(updates) - - await asyncio.sleep(1) # Adjust the sleep time as needed - - return EventSourceResponse(log_generator()) +@app.post("/api/logs_poll") +async def logs_poll( + req: LogsPollRequest = Body(...) +): + """ + Polling endpoint for logs. Client sends a dict of {username: num_logs_already_have}. + Returns new log entries for each username since that count, deduplicated by timestamp+interaction+username. + """ + log_positions = req.log_positions or {} + secret = req.secret + assert LOG_SECRET != "", "Logging secret not set." + if secret != LOG_SECRET: + return JSONResponse({"error": "Invalid secret."}, status_code=403) + + updates = [] + log_files = {participant.stem: participant for participant in LOG_PATH.glob("*.jsonl")} + for username, log_file in log_files.items(): + if username == '.jsonl': + continue + with open(log_file, "r") as file: + all_lines = file.readlines() + # Deduplicate all entries by (timestamp, interaction, username) + seen = set() + deduped = [] + for line in all_lines: + try: + entry = json.loads(line) + key = f"{entry.get('timestamp')}|{entry.get('interaction')}|{entry.get('username')}" + if key not in seen: + seen.add(key) + deduped.append(entry) + except Exception: + continue + # Only send entries after the client's count + start_line = log_positions.get(username, 0) + new_entries = deduped[start_line:] + if new_entries: + updates.append({ + "username": username, + "logs": new_entries, + }) + return updates + + +@app.get("/api/download_logs") +async def download_logs(secret: str): + """ + Download all log files as a ZIP archive. + """ + assert LOG_SECRET != "", "Logging secret not set." + if secret != LOG_SECRET: + return JSONResponse({"error": "Invalid secret."}, status_code=403) + + # Create an in-memory bytes buffer + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: + for log_file in LOG_PATH.glob("*.jsonl"): + zipf.write(log_file, arcname=log_file.name) + zip_buffer.seek(0) + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": "attachment; filename=logs.zip"} + ) def get_participant_log_filename(username): diff --git a/frontend/src/logs/index.tsx b/frontend/src/logs/index.tsx index 69e47e6b..61cdb8f1 100644 --- a/frontend/src/logs/index.tsx +++ b/frontend/src/logs/index.tsx @@ -1,88 +1,225 @@ import * as ReactDOM from 'react-dom'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { SERVER_URL } from '@/api'; -import classes from './styles.module.css'; - interface Log { username: string; event: string; prompt: string; result: string; completion: string; - timestamp: string; + timestamp: number; + isBackend: boolean; + generation_type?: string; +} + +// Collapsible component for prompt/result/completion +function Collapsible({ text, maxWidth = 200 }: { text: any, maxWidth?: number }) { + // If text is an object, render as JSON + let displayText: string; + if (typeof text === 'object' && text !== null) { + displayText = JSON.stringify(text, null, 2); + } else { + displayText = String(text ?? ''); + } + return ( +
+ {displayText.length > 100 ? displayText.slice(0, 100) + '…' : displayText} +
{displayText}
+
+ ); +} + +function secondsToHMS(seconds: number) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + const pad = (x: number) => x.toString().padStart(2, '0'); + return `${pad(m)}m${pad(s)}`; +} + +function getInteractionColor(interaction: string) { + if (interaction.includes('Backend')) return '#BBE9FF'; + if (interaction.includes('Frontend')) return '#FFEADD'; + if (interaction.includes('Sensitivity')) return '#FFFED3'; + return '#FFFFFF'; +} + +function EntriesTable({ entries }: { entries: Log[] }) { + let lastTimestamp: number | null = null; + const annotatedEntries = entries.map((entry) => { + const newEntry = { ...entry } as any; + if (lastTimestamp !== null) { + newEntry.secondsSinceLast = (entry.timestamp - lastTimestamp) / 1000; + } + newEntry.secondsSinceStart = (entry.timestamp - entries[0].timestamp) / 1000; + lastTimestamp = entry.timestamp; + return newEntry; + }).reverse(); + + return ( + + + + + + + + + + + + {annotatedEntries.map((entry: any, i: number) => ( + + + + + + + + ))} + +
TimestampInteractionPromptResultCompletion
{secondsToHMS(entry.secondsSinceStart)}{entry.interaction}
+ ); } function App() { - const [logs, updateLogs] = useState([]); + const [logs, setLogs] = useState([]); + const [username, setUsername] = useState(''); + const [logSecret, setLogSecret] = useState(() => localStorage.getItem('logSecret') || ''); + const logsRef = useRef([]); + const pollingRef = useRef(null); - const [usernameFilter, updateUsernameFilter] = useState(''); + // Helper: get log counts per username + const getLogCounts = (logs: Log[]) => { + const counts: Record = {}; + for (const log of logs) { + counts[log.username] = (counts[log.username] || 0) + 1; + } + return counts; + }; + // Poll logs from server useEffect(() => { - (async function() { - const streamer = new EventSource(`${SERVER_URL}/logs`); - - streamer.onmessage = (event) => { - const parsedLogs = JSON.parse(event.data); - - const newLogs = parsedLogs.map((log: {logs: Log[]}) => log.logs).flat(); - updateLogs([...logs, ...newLogs]); - }; - })(); - }, []); + if (!logSecret) return; + let stopped = false; + async function pollLogs() { + if (stopped) return; + const logCounts = getLogCounts(logsRef.current); + try { + const resp = await fetch(`${SERVER_URL}/logs_poll`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + // eslint-disable-next-line camelcase + body: JSON.stringify({ log_positions: logCounts, secret: logSecret }), + }); + if (resp.ok) { + const updates = await resp.json(); + let newLogs: Log[] = updates.map((log: { logs: Log[] }) => log.logs).flat(); + newLogs = newLogs.map((x) => { + const ts = typeof x.timestamp === 'string' ? new Date(x.timestamp).getTime() / 1000 : x.timestamp; + const isBackend = ['suggestion_generated', 'reflection_generated', 'reflection_generated'].includes(x.event); + return { + ...x, + timestamp: ts, + isBackend, + }; + }); + // Just append new logs (no deduplication) + const allLogs = [...logsRef.current, ...newLogs]; + logsRef.current = allLogs; + setLogs(allLogs); + } + } catch (e) { + // Optionally handle error + } + if (!stopped) { + pollingRef.current = setTimeout(pollLogs, 2000); + } + } + pollLogs(); + return () => { + stopped = true; + if (pollingRef.current) clearTimeout(pollingRef.current); + }; + }, [logSecret]); + + // Username datalist + const availableUsernames = useMemo(() => { + return Array.from(new Set(logs.map(x => x.username))).sort(); + }, [logs]); + + // Filtered logs + const desiredEntries = useMemo(() => { + return logs.filter(x => + (!username || x.username === username) + ); + }, [logs, username]); + + // Generation type counts + const generationTypeCounts = useMemo(() => { + return Object.entries( + desiredEntries.reduce((acc: Record, x) => { + if (x.isBackend && x.generation_type) { + acc[x.generation_type] = (acc[x.generation_type] || 0) + 1; + } + return acc; + }, {} as Record) + ).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([k, v]) => ({ generationType: k, count: v as number })); + }, [desiredEntries]); return ( - <> -
- updateUsernameFilter(e.target.value) } /> +
+
+ +
+
+ +
+
+ {desiredEntries.length} entries selected of {logs.length} total entries.
+ Last entry: {desiredEntries.length > 0 ? new Date(desiredEntries[desiredEntries.length - 1].timestamp * 1000).toLocaleString() : 'No entries'} +
+
+ Generation Type Counts: +
    + {generationTypeCounts.map(({ generationType, count }: { generationType: string, count: number }) => ( +
  • {generationType}: {count}
  • + ))} +
- { - ( - function() { - const groupedLogs = logs.reduce((acc: any, log) => { - if (!acc[log.username]) { - acc[log.username] = []; - } - - acc[log.username].push(log); - - return acc; - }, {}); - - // const filteredLogs = logs.filter((log) => log.username.includes(usernameFilter)); - - return ( - Object.keys(groupedLogs).filter( - username => username.includes(usernameFilter) - ).map( - username => ( -
-

{ username }

- -
    - { - groupedLogs[username].map((log: Log) => ( -
  • -

    Event: { log.event }

    -

    Prompt: { log.prompt }

    -

    Result: { log.result }

    -

    Completion: { log.completion }

    -

    Timestamp: { log.timestamp }

    -
  • - )) - } -
-
- ) - ) - ); - }() - ) - } - + +
); } ReactDOM.render(, document.getElementById('container')); + +// Add styles for collapsible diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index ac43f405..083924cd 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -162,6 +162,11 @@ module.exports = async (env, options) => { template: './src/editor/editor.html', chunks: ['editor', 'react'] }), + new HtmlWebpackPlugin({ + filename: 'logs.html', + template: './src/logs/logs.html', + chunks: ['logs', 'react'] + }), new HtmlWebpackPlugin({ filename: 'popup.html', template: './src/popup.html',