Souvikbasur commited on
Commit
ccb1bbd
·
0 Parent(s):

Add Hugging Face README config properly encoded

Browse files
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