Spaces:
Sleeping
Sleeping
Commit ·
ccb1bbd
0
Parent(s):
Add Hugging Face README config properly encoded
Browse files- Dockerfile +18 -0
- README.md +10 -0
- backend/__init__.py +0 -0
- backend/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/__pycache__/config.cpython-313.pyc +0 -0
- backend/__pycache__/main.cpython-313.pyc +0 -0
- backend/__pycache__/orchestrator.cpython-313.pyc +0 -0
- backend/__pycache__/security.cpython-313.pyc +0 -0
- backend/agents/__init__.py +0 -0
- backend/agents/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/agents/__pycache__/aggregator_agent.cpython-313.pyc +0 -0
- backend/agents/__pycache__/book_agent.cpython-313.pyc +0 -0
- backend/agents/__pycache__/overview_agent.cpython-313.pyc +0 -0
- backend/agents/__pycache__/paper_agent.cpython-313.pyc +0 -0
- backend/agents/__pycache__/video_agent.cpython-313.pyc +0 -0
- backend/agents/aggregator_agent.py +68 -0
- backend/agents/overview_agent.py +98 -0
- backend/agents/paper_agent.py +103 -0
- backend/agents/video_agent.py +56 -0
- backend/config.py +30 -0
- backend/main.py +50 -0
- backend/orchestrator.py +102 -0
- backend/security.py +74 -0
- requirements.txt +7 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install dependencies
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy the rest of the application
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
# Hugging Face Spaces and Koyeb use port 7860 or 8000.
|
| 13 |
+
# We'll use 7860 as it's the HF default.
|
| 14 |
+
ENV PORT=7860
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
|
| 17 |
+
# Run the FastAPI server
|
| 18 |
+
CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT
|
README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Knowledge Loom Backend
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
This is the FastAPI backend for Knowledge Loom.
|
backend/__init__.py
ADDED
|
File without changes
|
backend/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (116 Bytes). View file
|
|
|
backend/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (959 Bytes). View file
|
|
|
backend/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (1.88 kB). View file
|
|
|
backend/__pycache__/orchestrator.cpython-313.pyc
ADDED
|
Binary file (3.92 kB). View file
|
|
|
backend/__pycache__/security.cpython-313.pyc
ADDED
|
Binary file (3.73 kB). View file
|
|
|
backend/agents/__init__.py
ADDED
|
File without changes
|
backend/agents/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (123 Bytes). View file
|
|
|
backend/agents/__pycache__/aggregator_agent.cpython-313.pyc
ADDED
|
Binary file (3.09 kB). View file
|
|
|
backend/agents/__pycache__/book_agent.cpython-313.pyc
ADDED
|
Binary file (5.59 kB). View file
|
|
|
backend/agents/__pycache__/overview_agent.cpython-313.pyc
ADDED
|
Binary file (3.55 kB). View file
|
|
|
backend/agents/__pycache__/paper_agent.cpython-313.pyc
ADDED
|
Binary file (5.25 kB). View file
|
|
|
backend/agents/__pycache__/video_agent.cpython-313.pyc
ADDED
|
Binary file (2.34 kB). View file
|
|
|
backend/agents/aggregator_agent.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AGGREGATOR AGENT
|
| 3 |
+
----------------
|
| 4 |
+
Job: Take the raw outputs of the specialist agents and merge them into
|
| 5 |
+
one clean, well-formatted Markdown study report.
|
| 6 |
+
|
| 7 |
+
Tool used: Gemini for text formatting and synthesis only.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def run_aggregator_agent(model, topic: str, overview: dict, videos: dict,
|
| 12 |
+
papers: dict) -> str:
|
| 13 |
+
video_lines = "\n".join(
|
| 14 |
+
f"- [{v['title']}]({v['url']}) - {v['channel']}"
|
| 15 |
+
for v in videos.get("videos", [])
|
| 16 |
+
) or "_No videos found._"
|
| 17 |
+
|
| 18 |
+
paper_lines = "\n".join(
|
| 19 |
+
f"- **{p['title']}** ({p['year']}) - {p['authors']} - "
|
| 20 |
+
f"{p['citations']} citations. [Link]({p['url']})"
|
| 21 |
+
for p in papers.get("papers", [])
|
| 22 |
+
) or "_No papers found._"
|
| 23 |
+
|
| 24 |
+
prompt = f"""
|
| 25 |
+
You are the final editor assembling a research study guide on the topic: "{topic}".
|
| 26 |
+
|
| 27 |
+
Combine the material below into a single clean Markdown report with this
|
| 28 |
+
exact structure. Do not add facts that are not present in the material.
|
| 29 |
+
Lightly polish wording only; do not rewrite the overview's substance.
|
| 30 |
+
|
| 31 |
+
# {topic}
|
| 32 |
+
|
| 33 |
+
## Overview
|
| 34 |
+
{overview.get('content', '')}
|
| 35 |
+
|
| 36 |
+
## Recommended Videos
|
| 37 |
+
{video_lines}
|
| 38 |
+
|
| 39 |
+
## Research Paper Suggestions
|
| 40 |
+
{paper_lines}
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
Return the final Markdown report only, nothing else.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
response = model["client"].models.generate_content(
|
| 48 |
+
model=model["model_name"], contents=prompt
|
| 49 |
+
)
|
| 50 |
+
return response.text.strip()
|
| 51 |
+
except Exception as e:
|
| 52 |
+
# Programmatic backup formatting if the Gemini service fails (e.g., due to a 429 rate limit)
|
| 53 |
+
fallback_report = f"""# {topic}
|
| 54 |
+
|
| 55 |
+
## Overview
|
| 56 |
+
{overview.get('content', '')}
|
| 57 |
+
|
| 58 |
+
## Recommended Videos
|
| 59 |
+
{video_lines}
|
| 60 |
+
|
| 61 |
+
## Research Paper Suggestions
|
| 62 |
+
{paper_lines}
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
*Note: This report was assembled using the system's rule-based programmatic backup aggregator because the Gemini LLM synthesis service is currently rate-limited ({e}).*
|
| 66 |
+
"""
|
| 67 |
+
return fallback_report.strip()
|
| 68 |
+
|
backend/agents/overview_agent.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent 1: OVERVIEW AGENT
|
| 3 |
+
------------------------
|
| 4 |
+
Job: Given a topic, produce a clear, structured explanation:
|
| 5 |
+
- what it is
|
| 6 |
+
- why it matters
|
| 7 |
+
- 4-6 key sub-concepts a learner should know
|
| 8 |
+
|
| 9 |
+
Tool used: Gemini with Google Search grounding (falls back to plain
|
| 10 |
+
generation if grounding isn't available in the SDK version installed).
|
| 11 |
+
|
| 12 |
+
Only holds the GEMINI_API_KEY — no other credential is passed in.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from google import genai
|
| 16 |
+
from google.genai import types
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def build_overview_agent(api_key: str, model_name: str):
|
| 20 |
+
"""Returns a dict bundling the client + model name (the 'agent')."""
|
| 21 |
+
client = genai.Client(api_key=api_key)
|
| 22 |
+
return {"client": client, "model_name": model_name}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_overview_agent(agent, topic: str) -> dict:
|
| 26 |
+
client = agent["client"]
|
| 27 |
+
model_name = agent["model_name"]
|
| 28 |
+
|
| 29 |
+
prompt = (
|
| 30 |
+
f"You are an educational research assistant. The user wants to learn "
|
| 31 |
+
f"about: \"{topic}\".\n\n"
|
| 32 |
+
"Produce a well-structured overview with these exact sections:\n"
|
| 33 |
+
"1. **What it is** (2-3 sentences, plain language)\n"
|
| 34 |
+
"2. **Why it matters** (2-3 sentences)\n"
|
| 35 |
+
"3. **Key concepts to understand** (4-6 bullet points, each one line)\n"
|
| 36 |
+
"4. **Common misconceptions** (1-2 bullet points, if any exist)\n\n"
|
| 37 |
+
"Be accurate and concise. Do not invent facts. If you are uncertain "
|
| 38 |
+
"about something, say so rather than guessing."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
import time
|
| 42 |
+
|
| 43 |
+
# Simple retry helper to tolerate rate limits (429)
|
| 44 |
+
response = None
|
| 45 |
+
grounded = False
|
| 46 |
+
last_error = None
|
| 47 |
+
|
| 48 |
+
for attempt in range(3):
|
| 49 |
+
try:
|
| 50 |
+
# Attempt Google Search grounding so the overview is based on
|
| 51 |
+
# current web results, not just the model's training data.
|
| 52 |
+
response = client.models.generate_content(
|
| 53 |
+
model=model_name,
|
| 54 |
+
contents=prompt,
|
| 55 |
+
config=types.GenerateContentConfig(
|
| 56 |
+
tools=[types.Tool(google_search=types.GoogleSearch())]
|
| 57 |
+
),
|
| 58 |
+
)
|
| 59 |
+
grounded = True
|
| 60 |
+
break
|
| 61 |
+
except Exception as e:
|
| 62 |
+
last_error = e
|
| 63 |
+
if "429" in str(e) or "RESOURCE_EXHAUSTED" in str(e):
|
| 64 |
+
time.sleep(2 * (attempt + 1)) # Exponential backoff
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# If not a 429, try a fallback: plain generation without grounding
|
| 68 |
+
try:
|
| 69 |
+
response = client.models.generate_content(
|
| 70 |
+
model=model_name, contents=prompt
|
| 71 |
+
)
|
| 72 |
+
grounded = False
|
| 73 |
+
break
|
| 74 |
+
except Exception as inner_e:
|
| 75 |
+
last_error = inner_e
|
| 76 |
+
if "429" in str(inner_e) or "RESOURCE_EXHAUSTED" in str(inner_e):
|
| 77 |
+
time.sleep(2 * (attempt + 1))
|
| 78 |
+
continue
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
if response is not None:
|
| 82 |
+
return {
|
| 83 |
+
"agent": "overview_agent",
|
| 84 |
+
"grounded_with_search": grounded,
|
| 85 |
+
"content": response.text.strip(),
|
| 86 |
+
}
|
| 87 |
+
else:
|
| 88 |
+
# Graceful degradation if Gemini key is fully exhausted/blocked
|
| 89 |
+
return {
|
| 90 |
+
"agent": "overview_agent",
|
| 91 |
+
"grounded_with_search": False,
|
| 92 |
+
"content": (
|
| 93 |
+
f"_AI-generated overview is temporarily unavailable (Gemini API quota exhausted/rate limit hit). "
|
| 94 |
+
f"Please verify your API key limits. Topic requested: \"{topic}\"_"
|
| 95 |
+
),
|
| 96 |
+
"error": str(last_error)
|
| 97 |
+
}
|
| 98 |
+
|
backend/agents/paper_agent.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent 3: PAPER AGENT
|
| 3 |
+
---------------------
|
| 4 |
+
Job: Given a topic, find the most important / most-cited research papers.
|
| 5 |
+
|
| 6 |
+
Tool used: OpenAlex API. It is free, structured, and does not require an
|
| 7 |
+
API key. Results are sorted by citation count.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
+
import requests
|
| 14 |
+
|
| 15 |
+
OPENALEX_WORKS_URL = "https://api.openalex.org/works"
|
| 16 |
+
OPENALEX_MAILTO = "noreply@edu-research-agent.local"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _abstract_from_inverted_index(inverted_index: dict | None) -> str:
|
| 20 |
+
if not inverted_index:
|
| 21 |
+
return ""
|
| 22 |
+
|
| 23 |
+
positions = []
|
| 24 |
+
for word, indexes in inverted_index.items():
|
| 25 |
+
for index in indexes:
|
| 26 |
+
positions.append((index, word))
|
| 27 |
+
|
| 28 |
+
return " ".join(word for _, word in sorted(positions))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _get_openalex(params: dict, headers: dict) -> dict:
|
| 32 |
+
params = {**params, "mailto": OPENALEX_MAILTO}
|
| 33 |
+
last_error = None
|
| 34 |
+
|
| 35 |
+
for wait_seconds in (0, 2, 5):
|
| 36 |
+
if wait_seconds:
|
| 37 |
+
time.sleep(wait_seconds)
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
resp = requests.get(
|
| 41 |
+
OPENALEX_WORKS_URL, params=params, headers=headers, timeout=10
|
| 42 |
+
)
|
| 43 |
+
resp.raise_for_status()
|
| 44 |
+
return resp.json()
|
| 45 |
+
except requests.HTTPError as e:
|
| 46 |
+
last_error = e
|
| 47 |
+
if e.response is None or e.response.status_code != 429:
|
| 48 |
+
raise
|
| 49 |
+
except requests.RequestException as e:
|
| 50 |
+
last_error = e
|
| 51 |
+
raise
|
| 52 |
+
|
| 53 |
+
raise last_error
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def run_paper_agent(api_key: str, topic: str, max_results: int = 10) -> dict:
|
| 57 |
+
query = re.sub(r"\bner\b", "named entity recognition", topic, flags=re.IGNORECASE)
|
| 58 |
+
params = {
|
| 59 |
+
"filter": f"title_and_abstract.search:{query}",
|
| 60 |
+
"per-page": max_results,
|
| 61 |
+
"sort": "cited_by_count:desc",
|
| 62 |
+
"select": (
|
| 63 |
+
"display_name,authorships,publication_year,cited_by_count,"
|
| 64 |
+
"abstract_inverted_index,doi,id,primary_location"
|
| 65 |
+
),
|
| 66 |
+
}
|
| 67 |
+
headers = {"User-Agent": "edu-research-agent/1.0"}
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
data = _get_openalex(params, headers)
|
| 71 |
+
except requests.RequestException as e:
|
| 72 |
+
return {"agent": "paper_agent", "papers": [], "error": str(e)}
|
| 73 |
+
|
| 74 |
+
if not data.get("results"):
|
| 75 |
+
params.pop("filter", None)
|
| 76 |
+
params["search"] = query
|
| 77 |
+
try:
|
| 78 |
+
data = _get_openalex(params, headers)
|
| 79 |
+
except requests.RequestException as e:
|
| 80 |
+
return {"agent": "paper_agent", "papers": [], "error": str(e)}
|
| 81 |
+
|
| 82 |
+
cleaned = []
|
| 83 |
+
for paper in data.get("results", [])[:max_results]:
|
| 84 |
+
authors = ", ".join(
|
| 85 |
+
item.get("author", {}).get("display_name", "")
|
| 86 |
+
for item in (paper.get("authorships") or [])[:3]
|
| 87 |
+
)
|
| 88 |
+
location = paper.get("primary_location") or {}
|
| 89 |
+
source = location.get("source") or {}
|
| 90 |
+
url = location.get("landing_page_url") or paper.get("doi") or paper.get("id")
|
| 91 |
+
abstract = _abstract_from_inverted_index(paper.get("abstract_inverted_index"))
|
| 92 |
+
|
| 93 |
+
cleaned.append({
|
| 94 |
+
"title": paper.get("display_name"),
|
| 95 |
+
"authors": authors or "Unknown",
|
| 96 |
+
"year": paper.get("publication_year"),
|
| 97 |
+
"citations": paper.get("cited_by_count"),
|
| 98 |
+
"venue": source.get("display_name"),
|
| 99 |
+
"url": url,
|
| 100 |
+
"abstract": abstract[:300],
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
return {"agent": "paper_agent", "papers": cleaned, "error": None}
|
backend/agents/video_agent.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent 2: VIDEO AGENT
|
| 3 |
+
---------------------
|
| 4 |
+
Job: Given a topic, find the most relevant YouTube videos.
|
| 5 |
+
|
| 6 |
+
Tool used: YouTube Data API v3 (search.list endpoint) — a structured,
|
| 7 |
+
official API call. No scraping, no visiting youtube.com directly.
|
| 8 |
+
|
| 9 |
+
Only holds the YOUTUBE_API_KEY.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def run_video_agent(api_key: str, topic: str, max_results: int = 5) -> dict:
|
| 18 |
+
if not api_key or "YOUR_YOUTUBE_API_KEY_HERE" in api_key:
|
| 19 |
+
return {
|
| 20 |
+
"agent": "video_agent",
|
| 21 |
+
"videos": [],
|
| 22 |
+
"error": "YouTube API key not configured.",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
params = {
|
| 26 |
+
"part": "snippet",
|
| 27 |
+
"q": f"{topic} explained",
|
| 28 |
+
"type": "video",
|
| 29 |
+
"maxResults": max_results,
|
| 30 |
+
"order": "relevance",
|
| 31 |
+
"safeSearch": "strict",
|
| 32 |
+
"key": api_key,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
resp = requests.get(YOUTUBE_SEARCH_URL, params=params, timeout=10)
|
| 37 |
+
resp.raise_for_status()
|
| 38 |
+
data = resp.json()
|
| 39 |
+
except requests.RequestException as e:
|
| 40 |
+
return {"agent": "video_agent", "videos": [], "error": str(e)}
|
| 41 |
+
|
| 42 |
+
videos = []
|
| 43 |
+
for item in data.get("items", []):
|
| 44 |
+
video_id = item.get("id", {}).get("videoId")
|
| 45 |
+
snippet = item.get("snippet", {})
|
| 46 |
+
if not video_id:
|
| 47 |
+
continue
|
| 48 |
+
videos.append({
|
| 49 |
+
"title": snippet.get("title"),
|
| 50 |
+
"channel": snippet.get("channelTitle"),
|
| 51 |
+
"published": snippet.get("publishedAt"),
|
| 52 |
+
"url": f"https://www.youtube.com/watch?v={video_id}",
|
| 53 |
+
"thumbnail": snippet.get("thumbnails", {}).get("medium", {}).get("url"),
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
return {"agent": "video_agent", "videos": videos, "error": None}
|
backend/config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
config.py
|
| 3 |
+
---------
|
| 4 |
+
Central place where required API keys are loaded.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
# Gemini API key - used by Overview Agent, Aggregator Agent, and Safety Gate.
|
| 14 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "YOUR_GEMINI_API_KEY_HERE")
|
| 15 |
+
|
| 16 |
+
# YouTube Data API v3 key - used by Video Agent.
|
| 17 |
+
YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY", "YOUR_YOUTUBE_API_KEY_HERE")
|
| 18 |
+
|
| 19 |
+
# Model name used for Gemini calls.
|
| 20 |
+
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def validate_keys():
|
| 24 |
+
"""Warn if required keys are still placeholders."""
|
| 25 |
+
missing = []
|
| 26 |
+
if "YOUR_GEMINI_API_KEY_HERE" in GEMINI_API_KEY:
|
| 27 |
+
missing.append("GEMINI_API_KEY")
|
| 28 |
+
if "YOUR_YOUTUBE_API_KEY_HERE" in YOUTUBE_API_KEY:
|
| 29 |
+
missing.append("YOUTUBE_API_KEY")
|
| 30 |
+
return missing
|
backend/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
main.py
|
| 3 |
+
-------
|
| 4 |
+
FastAPI backend exposing a single endpoint:
|
| 5 |
+
|
| 6 |
+
POST /research
|
| 7 |
+
body: {"topic": "reinforcement learning"}
|
| 8 |
+
returns: {status, reason, report, agent_log}
|
| 9 |
+
|
| 10 |
+
Run with:
|
| 11 |
+
uvicorn backend.main:app --reload --port 8000
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI
|
| 15 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
+
from pydantic import BaseModel
|
| 17 |
+
|
| 18 |
+
from backend.orchestrator import run_research_pipeline
|
| 19 |
+
from backend import config
|
| 20 |
+
|
| 21 |
+
app = FastAPI(title="Education Research Agent API")
|
| 22 |
+
|
| 23 |
+
# Allow the Streamlit frontend (running on a different port) to call this API
|
| 24 |
+
app.add_middleware(
|
| 25 |
+
CORSMiddleware,
|
| 26 |
+
allow_origins=["*"],
|
| 27 |
+
allow_methods=["*"],
|
| 28 |
+
allow_headers=["*"],
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ResearchRequest(BaseModel):
|
| 33 |
+
topic: str
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@app.get("/health")
|
| 37 |
+
def health_check():
|
| 38 |
+
missing = config.validate_keys()
|
| 39 |
+
return {
|
| 40 |
+
"status": "ok",
|
| 41 |
+
"missing_required_keys": missing,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.post("/research")
|
| 46 |
+
def research(request: ResearchRequest):
|
| 47 |
+
topic = request.topic.strip()
|
| 48 |
+
if not topic:
|
| 49 |
+
return {"status": "error", "reason": "Empty topic.", "report": None, "agent_log": []}
|
| 50 |
+
return run_research_pipeline(topic)
|
backend/orchestrator.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
orchestrator.py
|
| 3 |
+
----------------
|
| 4 |
+
This is the "multi-agent system" coordination layer.
|
| 5 |
+
|
| 6 |
+
Flow:
|
| 7 |
+
1. Keyword pre-filter on the raw topic (fast, cheap safety gate)
|
| 8 |
+
2. Run Overview, Video, and Paper agents CONCURRENTLY (they don't
|
| 9 |
+
depend on each other's output)
|
| 10 |
+
3. Pass all 3 results into the Aggregator Agent to merge into one report
|
| 11 |
+
4. Run an LLM-based safety check on the FINAL report before returning it
|
| 12 |
+
5. Return structured status per agent + the final report
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 16 |
+
|
| 17 |
+
from backend import config
|
| 18 |
+
from backend.security import keyword_prefilter, llm_safety_check
|
| 19 |
+
from backend.agents.overview_agent import build_overview_agent, run_overview_agent
|
| 20 |
+
from backend.agents.video_agent import run_video_agent
|
| 21 |
+
from backend.agents.paper_agent import run_paper_agent
|
| 22 |
+
from backend.agents.aggregator_agent import run_aggregator_agent
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_research_pipeline(topic: str) -> dict:
|
| 26 |
+
# --- Step 1: fast pre-filter -------------------------------------------------
|
| 27 |
+
if not keyword_prefilter(topic):
|
| 28 |
+
return {
|
| 29 |
+
"status": "blocked",
|
| 30 |
+
"reason": "Topic blocked by pre-filter safety rule.",
|
| 31 |
+
"report": None,
|
| 32 |
+
"agent_log": [],
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
agent_log = []
|
| 36 |
+
|
| 37 |
+
# Shared Gemini model instance for Overview + Aggregator + Safety check.
|
| 38 |
+
# (Video/Paper agents use their own separate, scoped API keys.)
|
| 39 |
+
gemini_model = build_overview_agent(config.GEMINI_API_KEY, config.GEMINI_MODEL)
|
| 40 |
+
|
| 41 |
+
# --- Step 2: run the 3 specialist agents concurrently ------------------------
|
| 42 |
+
results = {}
|
| 43 |
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
| 44 |
+
futures = {
|
| 45 |
+
executor.submit(run_overview_agent, gemini_model, topic): "overview",
|
| 46 |
+
executor.submit(run_video_agent, config.YOUTUBE_API_KEY, topic): "videos",
|
| 47 |
+
executor.submit(run_paper_agent, "", topic, 10): "papers",
|
| 48 |
+
}
|
| 49 |
+
for future in as_completed(futures):
|
| 50 |
+
key = futures[future]
|
| 51 |
+
try:
|
| 52 |
+
results[key] = future.result()
|
| 53 |
+
agent_log.append(f"✅ {key}_agent completed successfully")
|
| 54 |
+
except Exception as e:
|
| 55 |
+
results[key] = {"error": str(e)}
|
| 56 |
+
agent_log.append(f"❌ {key}_agent failed: {e}")
|
| 57 |
+
|
| 58 |
+
# Guard against a completely failed overview (nothing to aggregate)
|
| 59 |
+
if "content" not in results.get("overview", {}):
|
| 60 |
+
return {
|
| 61 |
+
"status": "error",
|
| 62 |
+
"reason": "Overview agent failed — cannot build report.",
|
| 63 |
+
"report": None,
|
| 64 |
+
"agent_log": agent_log,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if "error" in results.get("overview", {}):
|
| 68 |
+
agent_log.append("⚠️ overview_agent rate-limited; loaded degraded fallback overview")
|
| 69 |
+
|
| 70 |
+
# --- Step 3: aggregate everything into one report ---------------------------
|
| 71 |
+
final_report = run_aggregator_agent(
|
| 72 |
+
gemini_model,
|
| 73 |
+
topic,
|
| 74 |
+
overview=results.get("overview", {}),
|
| 75 |
+
videos=results.get("videos", {}),
|
| 76 |
+
papers=results.get("papers", {}),
|
| 77 |
+
)
|
| 78 |
+
agent_log.append("✅ aggregator_agent merged all results")
|
| 79 |
+
|
| 80 |
+
# --- Step 4: safety gate on the FINAL report ---------------------------------
|
| 81 |
+
is_safe, reason = llm_safety_check(gemini_model, final_report)
|
| 82 |
+
if not is_safe:
|
| 83 |
+
agent_log.append(f"⛔ safety_gate blocked final report: {reason}")
|
| 84 |
+
return {
|
| 85 |
+
"status": "blocked",
|
| 86 |
+
"reason": reason,
|
| 87 |
+
"report": None,
|
| 88 |
+
"agent_log": agent_log,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if reason and "Bypassed" in reason:
|
| 92 |
+
agent_log.append(f"⚠️ safety_gate bypassed: {reason}")
|
| 93 |
+
else:
|
| 94 |
+
agent_log.append("✅ safety_gate passed")
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"status": "success",
|
| 98 |
+
"reason": None,
|
| 99 |
+
"report": final_report,
|
| 100 |
+
"agent_log": agent_log,
|
| 101 |
+
}
|
| 102 |
+
|
backend/security.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
security.py
|
| 3 |
+
-----------
|
| 4 |
+
Implements the two security controls we demonstrate for the capstone:
|
| 5 |
+
|
| 6 |
+
1. NON-INTERACTIVE ACCESS
|
| 7 |
+
No agent ever fetches or renders raw third-party HTML. Every agent talks
|
| 8 |
+
only to structured, official APIs (Gemini, YouTube Data API, Semantic
|
| 9 |
+
Scholar, Google Books). This removes the indirect-prompt-injection
|
| 10 |
+
surface that comes from an agent reading arbitrary web pages.
|
| 11 |
+
|
| 12 |
+
2. OUTPUT CONTENT SAFETY GATE (Governance / Logic Review)
|
| 13 |
+
Before the final report is returned to the user, the Aggregator Agent's
|
| 14 |
+
output passes through a lightweight safety classifier. If the topic or
|
| 15 |
+
generated content falls into a disallowed category (e.g. weapons,
|
| 16 |
+
self-harm instructions, hate content), the report is blocked and a safe
|
| 17 |
+
message is returned instead — mirroring the "Vibe Diff" / mandatory
|
| 18 |
+
logic-review idea from the security framework: nothing ships without a
|
| 19 |
+
check, even though the check here is automated rather than a human.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import re
|
| 23 |
+
|
| 24 |
+
# Minimal, fast keyword-based pre-filter. This runs before the LLM-based
|
| 25 |
+
# check so obviously disallowed topics are caught without spending tokens.
|
| 26 |
+
_BLOCKED_PATTERNS = [
|
| 27 |
+
r"\bmake\s+a\s+bomb\b",
|
| 28 |
+
r"\bsynthesiz(e|ing)\s+(nerve agent|sarin|ricin)\b",
|
| 29 |
+
r"\bhow to (kill|murder)\b",
|
| 30 |
+
r"\bchild\s+sexual\b",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def keyword_prefilter(topic: str) -> bool:
|
| 35 |
+
"""Returns True if topic is safe to proceed, False if blocked outright."""
|
| 36 |
+
lowered = topic.lower()
|
| 37 |
+
for pattern in _BLOCKED_PATTERNS:
|
| 38 |
+
if re.search(pattern, lowered):
|
| 39 |
+
return False
|
| 40 |
+
return True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def llm_safety_check(gemini_model, final_report_text: str) -> tuple[bool, str]:
|
| 44 |
+
"""
|
| 45 |
+
Uses Gemini itself as a lightweight judge over the FINAL aggregated
|
| 46 |
+
report (not the raw topic) to catch anything that slipped through.
|
| 47 |
+
|
| 48 |
+
Returns (is_safe, reason).
|
| 49 |
+
"""
|
| 50 |
+
prompt = (
|
| 51 |
+
"You are a content safety classifier. Read the study report below "
|
| 52 |
+
"and answer with exactly one word on the first line: SAFE or UNSAFE. "
|
| 53 |
+
"On the second line, give a one-sentence reason. "
|
| 54 |
+
"Mark UNSAFE only if the content provides actionable instructions "
|
| 55 |
+
"for violence, weapons, self-harm, or clearly illegal harmful acts. "
|
| 56 |
+
"Educational/academic framing of sensitive topics is SAFE.\n\n"
|
| 57 |
+
f"REPORT:\n{final_report_text[:4000]}"
|
| 58 |
+
)
|
| 59 |
+
try:
|
| 60 |
+
response = gemini_model["client"].models.generate_content(
|
| 61 |
+
model=gemini_model["model_name"], contents=prompt
|
| 62 |
+
)
|
| 63 |
+
text = response.text.strip()
|
| 64 |
+
first_line = text.splitlines()[0].strip().upper()
|
| 65 |
+
reason = text.splitlines()[1] if len(text.splitlines()) > 1 else ""
|
| 66 |
+
is_safe = first_line.startswith("SAFE")
|
| 67 |
+
return is_safe, reason
|
| 68 |
+
except Exception as e:
|
| 69 |
+
# If the check fails because of a 429 rate limit / quota exhaustion, bypass rather than block the whole application.
|
| 70 |
+
if "429" in str(e) or "RESOURCE_EXHAUSTED" in str(e) or "quota" in str(e).lower():
|
| 71 |
+
return True, f"Bypassed (API rate limit / quota exhausted)"
|
| 72 |
+
# Fail safe for other errors: if the classifier itself errors, don't silently ship unchecked content
|
| 73 |
+
return False, f"Safety check failed to run: {e}"
|
| 74 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
google-genai==0.3.0
|
| 4 |
+
requests==2.32.3
|
| 5 |
+
python-dotenv==1.0.1
|
| 6 |
+
pydantic==2.9.2
|
| 7 |
+
streamlit==1.38.0
|