# Project export: Sherlock

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2025
- Tagline: Connecting the dots for investigative minds.
- Devpost: https://devpost.com/software/sherlock-n4fikj
- GitHub: https://github.com/Honyant/TreeHacks2025
- Video: https://www.youtube.com/embed/n7HRhVSLlew?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner (Neo: Most Likely to Become a Business (Airfare + Accomodations at Neo Summer Retreat))
- Team: 4 GitHub contributor(s) — Neil Song (39 commits), Anthony Wang (23 commits), Alex Lan (19 commits), Ashley Zhou (14 commits)

## Devpost submission (written by the team)

### Overview

Investigative journalism is one of the most powerful tools for transparency, but it’s also one of the hardest jobs in the world. Reporters juggle huge amounts of unstructured data, struggle with limited resources, and, in many cases, risk their own safety to uncover the truth. The reality? Journalists today are drowning in information—leaked documents, financial records, interviews, and news reports pile up fast. Only 25% of newsrooms have dedicated investigative teams, meaning most journalists are forced to dig through mountains of data alone. The profession is getting more dangerous—2024 saw a record high of 124 journalists killed worldwide. The world desperately needs better tools to help reporters find connections faster, stay organized, and focus on the real work—telling the story. That’s why we built Sherlock. Sherlock is an AI-powered investigative assistant that connects the dots for journalists. Instead of dumping a long list of search results, Sherlock maps information into an interactive, evolving graph, showing how people, events, and documents are connected. Key Features: Graph-Based Story Mapping: A dynamic, AI-generated knowledge tree that visually organizes leads based on specificity and relevance, helping journalists spot hidden connections instantly. AI-Powered Lead Discovery: Sherlock suggests the next steps in an investigation, flagging missing data and recommending what to collect next. Hybrid Data Analysis (Public + Private): Unlike traditional AI tools that just search the web, Sherlock lets journalists upload private information documents and combines them with public sources for deeper insight. Conversational Data Input: Journalists can chat with Sherlock to add proprietary data—upload files, type notes, and refine leads in real-time. Clean, Intuitive UI: A beautiful, interactive graph-first interface that integrates everything—no more flipping between spreadsheets, notes, and PDFs. Sherlock is built on a multi-layered AI system combining LLMs, retrieval-augmented generation (RAG), and knowledge graph structures. Frontend: React + TailwindCSS + Vite Backend: FastAPI for lightweight, fast processing SMTP WebSockets + Twilio for email and phone communication & alerts OpenAI API for reasoning & text-based intelligence Perplexity API for retrieving high-quality, real-time public data Voyage AI, Unstructured, and Weaviate for RAG-based lead expansion In the backend, connecting Sherlock to multiple APIs, handling different file formats, and making sure everything talked to each other seamlessly was a massive task. Every new integration introduced new failure points and debugging nightmares. Some APIs also have strict rate limits, which meant we had to get creative with caching, batching requests, and optimizing queries to avoid bottlenecks. For the frontend, there was a steep learning curve in customizing React Flow for real-time graph updates and smooth interactions. In addition, settling on a type system that balanced developer sanity and runtime safety took way longer than expected. We started actually hacking around Saturday at noon, and in just over a day, we pulled off a fully functional AI-powered investigative tool with a complex backend, real-time graph UI, and multi-source data integration—a huge feat given the scope. Sherlock dramatically speeds up investigations, cutting hours of manual research into minutes by automatically organizing and expanding leads. Beyond just functionality, we’re proud of how usable and intuitive the final product feels—Sherlock was built with journalists in mind, prioritizing clarity, speed, and seamless interaction over complexity. More data sources for better lead expansion – Expanding integrations with news APIs, court records, and financial databases. Integrating more forms of proprietary data – Allowing users to upload data in other forms (text files, audio files, videos) would make the UI even more intuitive and accessible. Community-driven features – Creating a collaborative investigation mode, where teams can track and link findings together. Sherlock isn’t just an AI tool—it’s a revolutionary assistant that empowers investigative journalists to work faster, safer, and smarter. We're excited to see how it can change journalism.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 32 recognized source files, 118 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (45 of 45)

```
.gitignore
backend/.gitignore
backend/calltest/main.py
backend/end.sh
backend/README.md
backend/requirements.txt
backend/src/calltest.py
backend/src/database.py
backend/src/demo/brief.txt
backend/src/demo/nodes.json
backend/src/demo/ukraine_brief.txt
backend/src/engine.py
backend/src/external_functions.py
backend/src/main.py
backend/src/models.py
backend/src/RAG.py
backend/src/routes.py
backend/src/schemas.py
backend/src/utils.py
backend/start.sh
backend/stupidfrontend/index.html
frontend/.gitignore
frontend/bun.lock
frontend/eslint.config.js
frontend/index.html
frontend/package.json
frontend/README.md
frontend/src/api/client.ts
frontend/src/App.tsx
frontend/src/components/ChatBox.tsx
frontend/src/components/ExpandBox.tsx
frontend/src/index.css
frontend/src/main.tsx
frontend/src/openapi.d.ts
frontend/src/slices/chatSlice.ts
frontend/src/slices/graphSlice.ts
frontend/src/store.ts
frontend/src/utils/dump.json
frontend/src/utils/tree.tsx
frontend/src/vite-env.d.ts
frontend/tsconfig.app.json
frontend/tsconfig.json
frontend/tsconfig.node.json
frontend/typegen.sh
frontend/vite.config.ts
```

### Dependencies

- backend/requirements.txt: fastapi, openai, pdf2image, pi-heif, pydantic, python-dotenv, sqlalchemy, unstructured, unstructured_inference, unstructured_pytesseract, uvicorn, voyageai, weaviate-client
- frontend/package.json: @eslint/js@^9.19.0, @tailwindcss/vite@^4.0.6, @tanstack/eslint-plugin-query@^5.66.1, @tanstack/react-query@^5.66.0, @types/react@^19.0.8, @types/react-dom@^19.0.3, @vitejs/plugin-react@^4.3.4, @xyflow/react@^12.4.3, daisyui@^5.0.0-beta.8, entitree-flex@^0.4.1, eslint@^9.19.0, eslint-plugin-react-hooks@^5.0.0, eslint-plugin-react-refresh@^0.4.18, eslint-plugin-simple-import-sort@^12.1.1, globals@^15.14.0, openapi-fetch@^0.13.4, openapi-react-query@^0.3.0, openapi-typescript@^7.6.1, prettier@^3.5.1, react@^19.0.0, react-dom@^19.0.0, tailwind-merge@^3.0.1, tailwindcss@^4.0.6, typescript@~5.7.2, typescript-eslint@^8.22.0, vite@^6.1.0, zustand@^5.0.3

### Recent commits (newest first)

- added node ancestry content and made calling work
- calling, startup init, and chat working
- lint
- Merge branch 'master' of https://github.com/Honyant/TreeHacks2025
- chat clice
- start logging
- update requirements and RAG pdfs
- ukraine brief update
- better ergonomics
- update weaviate docker env
- sanity
- mode i execution with RAG
- fix circular
- fix circular
- ioqwpejfpojqwoeifjopwqfojiepw
- remove image requirements
- fix query
- mode i execution
- t pushMerge branch 'master' of https://github.com/Honyant/TreeHacks2025
- types

## Key source files (fetched from GitHub, selected and truncated for size)

### backend/requirements.txt

```
openai
fastapi
uvicorn
sqlalchemy
pydantic
python-dotenv
unstructured
weaviate-client
voyageai
unstructured
unstructured_inference
unstructured_pytesseract
pi-heif
pdf2image
```

### frontend/package.json

```
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "format": "prettier --write .",
    "openapi-typegen": "openapi-typescript http://localhost:8000/openapi.json --output src/openapi.d.ts",
    "typegen": "./typegen.sh"
  },
  "dependencies": {
    "@tanstack/react-query": "^5.66.0",
    "@xyflow/react": "^12.4.3",
    "entitree-flex": "^0.4.1",
    "openapi-fetch": "^0.13.4",
    "openapi-react-query": "^0.3.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwind-merge": "^3.0.1",
    "tailwindcss": "^4.0.6",
    "zustand": "^5.0.3"
  },
  "devDependencies": {
    "@eslint/js": "^9.19.0",
    "@tailwindcss/vite": "^4.0.6",
    "@tanstack/eslint-plugin-query": "^5.66.1",
    "@types/react": "^19.0.8",
    "@types/react-dom": "^19.0.3",
    "@vitejs/plugin-react": "^4.3.4",
    "daisyui": "^5.0.0-beta.8",
    "eslint": "^9.19.0",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.18",
    "eslint-plugin-simple-import-sort": "^12.1.1",
    "globals": "^15.14.0",
    "openapi-typescript": "^7.6.1",
    "prettier": "^3.5.1",
    "typescript": "~5.7.2",
    "typescript-eslint": "^8.22.0",
    "vite": "^6.1.0"
  }
}

```

### frontend/src/main.tsx

```typescript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client";
import { ReactFlowProvider } from "@xyflow/react";

import App from "./App.tsx";

import "@xyflow/react/dist/style.css";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
    },
  },
});

createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <ReactFlowProvider>
      <App />
    </ReactFlowProvider>
  </QueryClientProvider>
);

```

### backend/src/main.py

```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import models
from database import engine
from routes import router
import asyncio
import threading
from engine import check_for_replies
from routes import nodes, RAG_client, RAG_collection
from RAG import init_rag
from utils import get_node_by_id
from engine import init_agent

models.Base.metadata.create_all(bind=engine)

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(router)


@app.on_event("startup")
async def init_rag_at_startup():
    global nodes, RAG_client, RAG_collection
    
    """
    Initialize the RAG client, collection, and the root graph node at server startup.
    This will run only once when the FastAPI application starts.
    """
    if not nodes:  # Only initialize if nodes isn't already populated
        # RAG_client, RAG_collection = init_rag()
        root_node = init_agent(nodes, None)
        found_node = get_node_by_id(nodes, root_node)
        found_node.id = "0"
        found_node.type = "root"

# @app.on_event("startup")
# async def startup_event():
#     """Start the email checker in a separate thread when the FastAPI app starts"""

#     def run_email_checker():
#         check_for_replies()

#     # Start the email checker in a separate thread
#     email_thread = threading.Thread(target=run_email_checker, daemon=True)
#     email_thread.start()

```

### frontend/src/App.tsx

```typescript
import { useCallback, useEffect } from "react";
import {
  applyEdgeChanges,
  applyNodeChanges,
  ConnectionLineType,
  MiniMap,
  NodeProps,
  OnEdgesChange,
  OnNodesChange,
  OnSelectionChangeFunc,
  ReactFlow,
  SelectionMode,
  useReactFlow,
} from "@xyflow/react";
import { useShallow } from "zustand/shallow";

import { useClient } from "./api/client";
import { ChatBox } from "./components/ChatBox";
import { ExpandBox } from "./components/ExpandBox";
import { CustomNode, customNode } from "./utils/tree";
import { components } from "./openapi";
import { useStore } from "./store";

const nodeTypes: {
  [key in components["schemas"]["ChatMessageOut"]["graph"][string]["type"]]: React.FC<
    NodeProps<CustomNode>
  >;
} = {
  text: customNode,
  question: customNode,
  email: customNode,
  call: customNode,
  file: customNode,
  search: customNode,
  root: customNode,
};

// const { nodes: layoutedNodes, edges: layoutedEdges } =
//   layoutElements(initialTree);

// import { components } from "../openapi";
// const fetchedNodes: components["schemas"]["Graph"]["nodes"] = [];
// const fetchedEdges: components["schemas"]["Graph"]["edges"] = [];

const panOnDrag = [1, 2];

function App() {
  const {
    nodes,
    setNodes,
    edges,
    setEdges,
    setGraph,
    globalLoading,
    selectedNodeId,
    setSelectedNodeId,
  } = useStore(
    useShallow((state) => ({
      nodes: state.nodes,
      setNodes: state.setNodes,
      edges: state.edges,
      setEdges: state.setEdges,
      setGraph: state.setGraph,
      globalLoading: state.globalLoading,
      selectedNodeId: state.selectedNodeId,
      setSelectedNodeId: state.setSelectedNodeId,
    }))
  );

  const { setCenter } = useReactFlow();

  const onNodesChange: OnNodesChange<CustomNode> = useCallback(
    (changes) => {
      if (changes.some((c) => c.type === "add")) {
        const selectedNode = nodes.find((node) => node.id === selectedNodeId);
        if (!selectedNode) return;
        setCenter(
          selectedNode.position.x +
            (selectedNode.width ? selectedNode.width / 2 : 0),
          selectedNode.position.y +
            (selectedNode.height ? selectedNode.height / 2 : 0),
          {
            zoom: 0.7,
            duration: 1000,
          }
        );
      }
      setNodes((nds) => applyNodeChanges(changes, nds));
    },
    [setNodes, nodes, setCenter, selectedNodeId]
  );
  const onEdgesChange: OnEdgesChange = useCallback(
    (changes) => setEdges((edgs) => applyEdgeChanges(changes, edgs)),
    [setEdges]
  );

  const queryClient = useClient();
  const startMutation = queryClient.useMutation("post", "/start", {});

  useEffect(() => {
    (async () => {
      try {
        const data = await startMutation.mutateAsync({});
        const { nodes: layoutedNodes } = setGraph(data.graph);
        const rootNode = layoutedNodes.find((node) => node.data.isRoot)!;

        setCenter(
          rootNode.position.x + (rootNode.width ? rootNode.width / 2 : 0),
          rootNode.position.y + (rootNode.height ? rootNode.height / 2 : 0),
          {
            zoom: 1,
            duration: 1000,
          }
        );
      } catch (error) {
        console.error(error);
      }
    })();
  }, []);

  const onChange: OnSelectionChangeFunc = useCallback(
    ({ nodes }) => {
      if (nodes.length === 1) {
        setSelectedNodeId(nodes[0].id);
        const node = nodes[0];
        setCenter(
          node.position.x +
            (node.measured?.width ? node.measured.width / 2 : 0),
          node.position.y +
            (node.measured?.height ? node.measured.height / 2 : 0),
          {
            zoom: 1,
            duration: 1000,
          }
        );
      } else if (nodes.length === 0) {
        setSelectedNodeId(null);
      }
    },
    [setSelectedNodeId, setCenter]
  );

  return (
    <div className="w-screen h-screen">
      <div className="max-h-[10vh] min-h-[10vh]">
        {selectedNodeId ? <ChatBox /> : null}
        {selectedNodeId && <ExpandBox />}
        {globalLoading && <div>Loading...</div>}
      </div>

      <div style={{ width: "100vw", height: "90%" }}>
        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onSelectionChange={onChange}
          connectionLineType={ConnectionLineType.SmoothStep}
          fitView
          fitViewOptions={{
            padding: 0.2,
            duration: 1000,
            minZoom: 0.0001,
          }}
          nodeTypes={nodeTypes}
          panOnScroll
          selectionOnDrag
          panOnDrag={panOnDrag}
          selectionMode={SelectionMode.Partial}
          proOptions={{ hideAttribution: true }}
          nodesDraggable={false}
          onPaneClick={() => {
            setSelectedNodeId(null);
          }}
        >
          <MiniMap
            nodeStrokeWidth={3}
            position={"top-left"}
            bgColor="#1d232a"
            maskColor="transparent"
            maskStrokeColor="#fff"
            maskStrokeWidth={1.5}
            style={{ border: "1px dashed #fff", borderRadius: 10 }}
            pannable
          />
        </ReactFlow>
      </div>
    </div>
  );
}

export default App;

```

### backend/calltest/main.py

```python
import os
import json
import base64
import asyncio
import argparse
from fastapi import FastAPI, WebSocket, BackgroundTasks
from fastapi.responses import JSONResponse
from fastapi.websockets import WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from twilio.rest import Client
import websockets
from dotenv import load_dotenv
import uvicorn
import re
from typing import Optional
from pydantic import BaseModel
from openai import OpenAI

load_dotenv()

# Configuration
TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN")
PHONE_NUMBER_FROM = os.getenv("PHONE_NUMBER_FROM")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
raw_domain = os.getenv("NGROK_DOMAIN", "")
DOMAIN = re.sub(
    r"(^\w+:|^)\/\/|\/+$", "", raw_domain
)  # Strip protocols and trailing slashes from DOMAIN

print(f"TWILIO_ACCOUNT_SID: {TWILIO_ACCOUNT_SID}")
print(f"TWILIO_AUTH_TOKEN: {TWILIO_AUTH_TOKEN}")
print(f"PHONE_NUMBER_FROM: {PHONE_NUMBER_FROM}")
print(f"OPENAI_API_KEY: {OPENAI_API_KEY}")
print(f"DOMAIN: {DOMAIN}")
PORT = int(os.getenv("PORT", 6060))
SYSTEM_MESSAGE = (
    "You are an AI voice assistant to ask the user questions and gather information. You are talking to an expert on the topic of the call: {topic}. You will not ask the user clarifying questions, you will ask the user to answer the question directly."
)
TOPIC = "Your day"
VOICE = "alloy"
LOG_EVENT_TYPES = [
    "error",
    "response.content.done",
    "rate_limits.updated",
    "response.done",
    "input_audio_buffer.committed",
    "input_audio_buffer.speech_stopped",
    "input_audio_buffer.speech_started",
    "session.created",
]

app = FastAPI()

# Add CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Allows all origins
    allow_credentials=True,
    allow_methods=["*"],  # Allows all methods
    allow_headers=["*"],  # Allows all headers
)

if not (
    TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and PHONE_NUMBER_FROM and OPENAI_API_KEY
):
    raise ValueError(
        "Missing Twilio and/or OpenAI environment variables. Please set them in the .env file."
    )

# Initialize Twilio client
client = Client(
    TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN
)

# Initialize OpenAI client
openai_client = OpenAI(api_key=OPENAI_API_KEY)


# Add this new class for the request body
class CallRequest(BaseModel):
    phone_number: str
    topic: str
    max_duration: Optional[int] = 300  # default 5 minutes


# Add a dictionary to store call summaries
call_summaries = {}


@app.get("/", response_class=JSONResponse)
async def index_page():
    return {"message": "Twilio Media Stream Server is running!"}


@app.websocket("/media-stream")
async def handle_media_stream(websocket: WebSocket):
    """Handle WebSocket connections between Twilio and OpenAI."""
    print("Client connected")
    await websocket.accept()

    conversation_transcript = []

    async with websockets.connect(
        "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview",
        additional_headers={
            "Authorization": f"Bearer {OPENAI_API_KEY}",
            "OpenAI-Beta": "realtime=v1",
        },
    ) as openai_ws:
        await initialize_session(openai_ws)
        stream_sid = None

        async def receive_from_twilio():
            """Receive audio data from Twilio and send it to the OpenAI Realtime API."""
            nonlocal stream_sid
            try:
                async for message in websocket.iter_text():
                    data = json.loads(message)
                    if data["event"] == "media":
                        audio_append = {
                            "type": "input_audio_buffer.append",
                            "audio": data["media"]["payload"],
                        }
                        await openai_ws.send(json.dumps(audio_append))
                    elif data["event"] == "start":
                        stream_sid = data["start"]["streamSid"]
                        print(f"Incoming stream has started {stream_sid}")
            except WebSocketDisconnect:
                print("Client disconnected.")
                # if openai_ws.open:
                #     await openai_ws.close()

        async def send_to_twilio():
            """Receive events from the OpenAI Realtime API, send audio back to Twilio."""
            nonlocal stream_sid, conversation_transcript
            try:
                async for openai_message in openai_ws:
                    response = json.loads(openai_message)
                    if response["type"] in LOG_EVENT_TYPES:
                        print(f"Received event: {response['type']}", response)
                        pass
                    if response["type"] == "session.updated":
                        print("Session updated successfully:", response)
                        pass
                    if response["type"] == "response.audio.delta" and response.get(
                        "delta"
                    ):
                        try:
                            audio_payload = base64.b64encode(
                                base64.b64decode(response["delta"])
                            ).decode("utf-8")
                            audio_delta = {
                                "event": "media",
                                "streamSid": stream_sid,
                                "media": {"payload": audio_payload},
                            }
                            await websocket.send_json(audio_delta)
                        except Exception as e:
                            print(f"Error processing audio data: {e}")
                    if response["type"] == "response.content.delta":
                        if "delta" in response and "text" in response["delta"]:
                            conversation_transcript.append(response["delta"]["text"])
                        print(f"Total transcript: {conversation_transcript}")
                    # Handle end of call
    
[truncated — 7245 more characters]
```

### backend/end.sh

```shell
#!/bin/bash
if [ -f pid.txt ]; then 
    UVICORN_PID=$(cat pid.txt)
    kill $(pgrep -P $UVICORN_PID)
    rm pid.txt src/research.db
    sleep 1
fi
```

### frontend/vite.config.ts

```typescript
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

```

### backend/start.sh

```shell
#!/bin/bash
# Start script for the application

if [ -f pid.txt ]; then 
    UVICORN_PID=$(cat pid.txt)
    kill $(pgrep -P $UVICORN_PID)
    rm pid.txt src/research.db
    sleep 2
fi
nohup bash -c "source .venv/bin/activate && cd src && uvicorn main:app --reload" > output.log 2>&1 &
echo $! > pid.txt
```

### frontend/typegen.sh

```shell
#!/usr/bin/env bash

# This script generates TypeScript types for the APIs based on OpenAPI specifications.
# Add your commands below to implement the type generation functionality.

bash -c "cd ../backend && ./start.sh"
bun run openapi-typegen || {
  echo "TypeScript types generation failed."
}
bash -c "cd ../backend && ./end.sh"
```

[22 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]