From 0af9b3cc3d8c7590d6809c63999769ea2d4f7829 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:11:57 +0000 Subject: [PATCH 1/4] Initial plan From 969f2abab1917aefe2d174dad4b84d1ee820714f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:21:12 +0000 Subject: [PATCH 2/4] Add ScholarAI: Cloudflare Python Workers AI-powered research assistant Co-authored-by: A1L13N <193832434+A1L13N@users.noreply.github.com> --- README.md | 238 ++++++++- .../conftest.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 1472 bytes conftest.py | 26 + package.json | 14 + pyproject.toml | 19 + src/__pycache__/entry.cpython-312.pyc | Bin 0 -> 18965 bytes src/entry.py | 478 ++++++++++++++++++ .../test_entry.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 37323 bytes tests/test_entry.py | 339 +++++++++++++ wrangler.jsonc | 16 + 10 files changed, 1128 insertions(+), 2 deletions(-) create mode 100644 __pycache__/conftest.cpython-312-pytest-9.0.2.pyc create mode 100644 conftest.py create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 src/__pycache__/entry.cpython-312.pyc create mode 100644 src/entry.py create mode 100644 tests/__pycache__/test_entry.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_entry.py create mode 100644 wrangler.jsonc diff --git a/README.md b/README.md index 04caa50..aaeb04e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,236 @@ -# scholarai -AI-powered research assistant designed to help students and scientists navigate large volumes of academic literature. Supports paper discovery, summarization, citation exploration, and question answering across research papers. Helps organize knowledge, identify trends, and accelerate literature review workflows. +# ScholarAI + +AI-powered research assistant designed to help students and scientists navigate large volumes of academic literature. Supports paper discovery, summarization, citation exploration, and question answering across research papers. Helps organise knowledge, identify trends, and accelerate literature review workflows. + +Built as a **Cloudflare Python Worker** powered by **Cloudflare Workers AI** (`@cf/meta/llama-3.1-8b-instruct`). + +--- + +## Features + +| Feature | Endpoint | Description | +|---------|----------|-------------| +| Paper discovery | `POST /api/discover` | Find relevant papers for a query | +| Summarisation | `POST /api/summarize` | Structured summary of a paper | +| Citation exploration | `POST /api/citations` | Explore a paper's citation network | +| Question answering | `POST /api/qa` | Answer research questions from literature | +| Knowledge organisation | `POST /api/organize` | Cluster and map a reading list | +| Trend identification | `POST /api/trends` | Spot emerging and declining research trends | +| Literature review | `POST /api/review` | Generate a full literature review section | + +--- + +## Quick Start (local development) + +### Prerequisites + +- [Node.js](https://nodejs.org/) ≥ 18 +- [uv](https://github.com/astral-sh/uv) (Python package manager) + +```bash +# Install Node dependencies (Wrangler CLI) +npm install + +# Start the local development server (requires a free Cloudflare account) +npm run dev +``` + +The local server starts at `http://localhost:8787`. + +> **Note:** A Cloudflare account is required for the Workers AI binding. +> Run `npx wrangler login` before `npm run dev` to authenticate. + +--- + +## API Reference + +### `GET /` + +Returns API metadata and a usage guide for all endpoints. + +--- + +### `POST /api/discover` + +Discover relevant academic papers for a research query. + +**Request body:** +```json +{ + "query": "transformer models in NLP", + "fields": ["machine learning", "NLP"], + "limit": 10 +} +``` + +**Response:** +```json +{ + "query": "transformer models in NLP", + "results": { + "papers": [...], + "research_directions": [...], + "key_concepts": [...], + "related_queries": [...] + } +} +``` + +--- + +### `POST /api/summarize` + +Generate a structured summary of a research paper. + +**Request body** (at least one field required): +```json +{ + "title": "Attention Is All You Need", + "abstract": "We propose a new simple network architecture...", + "content": "Full paper text (optional, truncated to 4 000 chars)" +} +``` + +--- + +### `POST /api/citations` + +Explore a paper's citation network. + +**Request body:** +```json +{ + "paper": "Attention Is All You Need", + "type": "related" +} +``` + +`type` options: `forward`, `backward`, `related` (default: `related`). + +--- + +### `POST /api/qa` + +Answer a research question using the AI's knowledge and optional context. + +**Request body:** +```json +{ + "question": "What are the main advantages of self-attention over RNNs?", + "context": "Optional background text", + "papers": [{"title": "...", "year": 2017}] +} +``` + +--- + +### `POST /api/organize` + +Organise a reading list into thematic clusters and a knowledge map. + +**Request body:** +```json +{ + "papers": [ + {"title": "Paper A", "year": 2021}, + {"title": "Paper B", "year": 2022} + ], + "organize_by": "topic" +} +``` + +`organize_by` options: `topic`, `year`, `author`, `methodology` (default: `topic`). + +--- + +### `POST /api/trends` + +Identify research trends in a field or from a set of papers. + +**Request body** (at least one of `field` or `papers` required): +```json +{ + "field": "computer vision", + "time_range": "2018-2024", + "papers": [...] +} +``` + +--- + +### `POST /api/review` + +Generate a structured literature review. + +**Request body:** +```json +{ + "topic": "graph neural networks", + "papers": [...], + "style": "comprehensive", + "audience": "graduate students" +} +``` + +`style` options: `comprehensive`, `brief`, `systematic` (default: `comprehensive`). + +--- + +## Deployment + +```bash +# Deploy to Cloudflare Workers +npm run deploy +``` + +--- + +## Development + +### Running tests + +```bash +# Install dev dependencies +uv sync --group dev + +# Run tests +uv run pytest tests/ -v +``` + +### Project structure + +``` +scholarai/ +├── src/ +│ └── entry.py # FastAPI app + WorkerEntrypoint +├── tests/ +│ └── test_entry.py # Pytest tests with mocked AI binding +├── wrangler.jsonc # Cloudflare Workers configuration +├── pyproject.toml # Python project + dependencies +├── package.json # npm scripts for Wrangler CLI +└── README.md +``` + +--- + +## Architecture + +``` +HTTP Request + │ + ▼ +WorkerEntrypoint.fetch() ← Cloudflare Workers runtime + │ (via asgi bridge) + ▼ +FastAPI app ← routing, validation, error handling + │ + ▼ +Depends(get_env) ← injects Cloudflare env into each handler + │ + ▼ +env.AI.run(model, params) ← Cloudflare Workers AI (@cf/meta/llama-3.1-8b-instruct) + │ + ▼ +JSON Response +``` + diff --git a/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34516b1c871ead4f6a3b0985974aae802a13a89f GIT binary patch literal 1472 zcmb7EPjB2r6rZuZ@ve81wrN47YUzlGMk`gjYL6vENK1v1Z6ZWK6~2rdd$#Mw_H1Xy zjdqnvO9|o{1XB14D1tA-r4SC(q9-`OZL2*La^j8chEgaJmi+$CoA>7T-pseNvkrm} zTi=8WCPF`J<>?1{{!PCCwvPhjq5ua*gk2-&W^DNeMsdZR!G*_mZSa^;HLkg}+-5~i zTzBgrS6HoQ1T(vq+X!rC-#%CF{tUZ|#Y+Zajf2wm@2diP3H(E2Xf&&%Pn>s$ib+L$ zp7g?7sqj?DlWRn(bW;+ZDx&&~bRwPxy~q=c+~i`52}w5I?h?tt>nTSHzs(XQhONPn z_+COb842S77m5WWOo&GYkp~=7^?5?_d@Q*e3ZBFe*K&T-1+O=8=B)7`joACc0RyeJ z=G_YYHSTX^GlHp9Bz}3aoLN9M#?xgW`$*y33M9Y>rF;(==p7UoyC^UZjKY5p@8BIo zk5})Y4b-$oZ@!mjfMVk?31crJi`&In7fGB~m4iaIL)9-)xUSDU;2|6#v&+SQtMF-# z>C1VA47nJl0J`s*x+d2enoaBIGL5}0M#~MThQ75Fao>yNa;w1muS}yiHo#AMT>MvO zo{u7a(sI1G9F?I*?uTu(~p}aP0@cAphL&L4 zB2+A^9h-Dd3!4MMx=Yj(nJI@dt0=g~GCx{2nuo`phm6mg0;y<&bEw zj;c$AV_7VKs&A590WvW$#*YmXTTiM8<6ZQz^9Y@Pgcc?)hPltc@ ModuleType: + """Return a minimal 'workers' module stub with WorkerEntrypoint.""" + mod = ModuleType("workers") + + class WorkerEntrypoint: + """Stub for the Cloudflare Workers SDK WorkerEntrypoint class.""" + + async def fetch(self, request): # pragma: no cover + raise NotImplementedError("Use the Cloudflare Workers runtime") + + mod.WorkerEntrypoint = WorkerEntrypoint + return mod + + +# Install stub before any test module imports src.entry +if "workers" not in sys.modules: + sys.modules["workers"] = _make_workers_stub() diff --git a/package.json b/package.json new file mode 100644 index 0000000..569cf19 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "scholarai", + "version": "1.0.0", + "description": "AI-powered research assistant using Cloudflare Workers AI", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.46.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1212c8c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "scholarai" +version = "1.0.0" +description = "AI-powered research assistant using Cloudflare Workers AI" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", + "fastapi", + "markupsafe", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk", + "pytest", + "httpx", +] diff --git a/src/__pycache__/entry.cpython-312.pyc b/src/__pycache__/entry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ad57a8aa1a6895ebf54d8d631011a66597bfbb7 GIT binary patch literal 18965 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 dict | str: + """Attempt to extract and parse a JSON object from a text response.""" + start = text.find("{") + end = text.rfind("}") + 1 + if start >= 0 and end > start: + try: + return json.loads(text[start:end]) + except json.JSONDecodeError: + pass + return text + + +async def run_ai(env, system_prompt: str, user_prompt: str) -> str: + """Run inference via the Cloudflare Workers AI binding.""" + result = await env.AI.run( + AI_MODEL, + { + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + }, + ) + # The llama-3.1 model returns a dict with a "response" key + if isinstance(result, dict): + return result.get("response", "") + # Newer model objects may expose an attribute + return getattr(result, "response", str(result)) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@app.get("/", summary="API information and usage guide") +async def api_info(): + return { + "name": "ScholarAI", + "version": "1.0.0", + "description": ( + "AI-powered research assistant for academic literature, " + "powered by Cloudflare Workers AI." + ), + "endpoints": { + "GET /": "API information", + "POST /api/discover": "Discover relevant academic papers", + "POST /api/summarize": "Summarize a research paper", + "POST /api/citations": "Explore paper citations and related work", + "POST /api/qa": "Question answering about research topics", + "POST /api/organize": "Organize a collection of papers", + "POST /api/trends": "Identify research trends in a field", + "POST /api/review": "Generate a literature review", + }, + "usage": { + "discover": { + "method": "POST", + "body": { + "query": "string (required)", + "fields": "list[string] (optional)", + "limit": "int (optional, default: 10)", + }, + }, + "summarize": { + "method": "POST", + "body": { + "title": "string (optional)", + "abstract": "string (optional)", + "content": "string (optional)", + }, + }, + "citations": { + "method": "POST", + "body": { + "paper": "string (required)", + "type": "string (optional: forward | backward | related)", + }, + }, + "qa": { + "method": "POST", + "body": { + "question": "string (required)", + "context": "string (optional)", + "papers": "list[dict] (optional)", + }, + }, + "organize": { + "method": "POST", + "body": { + "papers": "list[dict] (required)", + "organize_by": "string (optional: topic | year | author | methodology)", + }, + }, + "trends": { + "method": "POST", + "body": { + "field": "string (optional)", + "papers": "list[dict] (optional)", + "time_range": "string (optional)", + }, + }, + "review": { + "method": "POST", + "body": { + "topic": "string (required)", + "papers": "list[dict] (optional)", + "style": "string (optional: comprehensive | brief | systematic)", + "audience": "string (optional)", + }, + }, + }, + } + + +@app.post("/api/discover", summary="Discover relevant academic papers") +async def discover_papers(body: DiscoverRequest, env=Depends(get_env)): + """ + Discover relevant academic papers for a research query. + Returns suggested papers, research directions, key concepts, and related queries. + """ + if env is None: + raise HTTPException(status_code=503, detail="AI binding not available") + + field_context = ( + f" in the fields of {', '.join(body.fields)}" if body.fields else "" + ) + system_prompt = ( + "You are ScholarAI, an expert academic research assistant. " + "You help researchers discover relevant academic papers and literature. " + "When given a research query, provide structured information about relevant " + "papers, key concepts, and research directions. " + "Format your response as a valid JSON object." + ) + user_prompt = ( + f"Discover academic papers for the following research query{field_context}:\n\n" + f"Query: {body.query}\n" + f"Requested limit: {body.limit} papers\n\n" + "Return a JSON object with keys: papers (list with title, authors, year, venue, " + "abstract_summary, relevance_score, key_topics), research_directions (list), " + "key_concepts (list), related_queries (list)." + ) + response_text = await run_ai(env, system_prompt, user_prompt) + return {"query": body.query, "results": _try_parse_json(response_text)} + + +@app.post("/api/summarize", summary="Summarize a research paper") +async def summarize_paper(body: SummarizeRequest, env=Depends(get_env)): + """ + Generate a structured summary of a research paper from its title, abstract, or full content. + """ + if env is None: + raise HTTPException(status_code=503, detail="AI binding not available") + + if not (body.title or body.abstract or body.content): + raise HTTPException( + status_code=400, + detail="At least one of title, abstract, or content is required", + ) + + parts = [] + if body.title: + parts.append(f"Title: {body.title}") + if body.abstract: + parts.append(f"Abstract: {body.abstract}") + if body.content: + # Limit content to avoid exceeding context window + parts.append(f"Content:\n{body.content[:4000]}") + + system_prompt = ( + "You are ScholarAI, an expert academic research assistant specialized in " + "summarizing research papers. Provide clear, concise, and accurate summaries " + "that capture the key contributions, methodology, results, and implications." + ) + user_prompt = ( + f"Summarise the following research paper:\n\n{chr(10).join(parts)}\n\n" + "Structure your summary with these sections:\n" + "1. Main contribution\n" + "2. Problem being solved\n" + "3. Methodology\n" + "4. Key findings / results\n" + "5. Limitations\n" + "6. Future work directions\n" + "7. Impact and significance" + ) + summary = await run_ai(env, system_prompt, user_prompt) + return {"title": body.title, "summary": summary} + + +@app.post("/api/citations", summary="Explore paper citations and related work") +async def explore_citations(body: CitationsRequest, env=Depends(get_env)): + """ + Analyse the citation network around a paper: foundational works, citing papers, + and related research. + """ + if env is None: + raise HTTPException(status_code=503, detail="AI binding not available") + + system_prompt = ( + "You are ScholarAI, an expert academic research assistant specializing in " + "citation analysis and research genealogy. Help researchers understand the " + "citation network around papers, including foundational work, recent advances, " + "and related research." + ) + user_prompt = ( + f"Explore citations and related work for the following paper:\n\n" + f"Paper: {body.paper}\n" + f"Citation type requested: {body.type}\n\n" + "Please provide:\n" + "1. Key foundational papers (seminal works this paper builds upon)\n" + "2. Papers that cite this work (if applicable)\n" + "3. Related papers in the same research area\n" + "4. Research lineage and evolution of ideas\n" + "5. Key authors and research groups in this area\n" + "6. Connections to adjacent research fields" + ) + result = await run_ai(env, system_prompt, user_prompt) + return {"paper": body.paper, "citation_type": body.type, "exploration": result} + + +@app.post("/api/qa", summary="Question answering about research topics") +async def question_answering(body: QARequest, env=Depends(get_env)): + """ + Answer a research question, drawing on provided context or papers and the + model's knowledge of the scientific literature. + """ + if env is None: + raise HTTPException(status_code=503, detail="AI binding not available") + + context_parts = [] + if body.context: + context_parts.append(f"Context:\n{body.context}") + if body.papers: + context_parts.append( + f"Available papers:\n{json.dumps(body.papers[:5], indent=2)}" + ) + + system_prompt = ( + "You are ScholarAI, an expert academic research assistant with deep knowledge " + "across multiple scientific disciplines. Answer research questions accurately, " + "citing relevant papers and providing evidence-based responses. " + "Be precise, thorough, and acknowledge uncertainty when appropriate." + ) + extra = ("\n\n" + "\n\n".join(context_parts)) if context_parts else "" + user_prompt = ( + f"Please answer the following research question:\n\n" + f"Question: {body.question}{extra}\n\n" + "Provide:\n" + "1. A comprehensive answer\n" + "2. Key evidence and findings from the literature\n" + "3. Relevant papers and references\n" + "4. Important caveats or limitations\n" + "5. Areas of ongoing debate or uncertainty" + ) + answer = await run_ai(env, system_prompt, user_prompt) + return {"question": body.question, "answer": answer} + + +@app.post("/api/organize", summary="Organise a collection of papers") +async def organize_knowledge(body: OrganizeRequest, env=Depends(get_env)): + """ + Organise a list of papers into thematic clusters, a knowledge map, and a + recommended reading order. + """ + if env is None: + raise HTTPException(status_code=503, detail="AI binding not available") + + system_prompt = ( + "You are ScholarAI, an expert academic research assistant specialized in " + "knowledge organization and taxonomy. Help researchers organize and structure " + "their literature collection for better understanding and navigation." + ) + papers_json = json.dumps(body.papers[:20], indent=2) + user_prompt = ( + f"Organise the following research papers by {body.organize_by}:\n\n" + f"Papers:\n{papers_json}\n\n" + "Please provide:\n" + "1. Organised groupings / clusters\n" + "2. Key themes and relationships between papers\n" + "3. A knowledge map showing connections\n" + "4. Recommended reading order\n" + "5. Research gaps identified from this collection\n" + "6. Cross-cutting themes and methodologies" + ) + result = await run_ai(env, system_prompt, user_prompt) + return {"organize_by": body.organize_by, "organization": result} + + +@app.post("/api/trends", summary="Identify research trends in a field") +async def identify_trends(body: TrendsRequest, env=Depends(get_env)): + """ + Identify emerging trends, hot topics, declining areas, and future directions + in a research field or from a set of papers. + """ + if env is None: + raise HTTPException(status_code=503, detail="AI binding not available") + + if not (body.field or body.papers): + raise HTTPException( + status_code=400, detail="At least one of field or papers is required" + ) + + context_parts = [] + if body.field: + context_parts.append(f"Research field: {body.field}") + if body.time_range: + context_parts.append(f"Time range: {body.time_range}") + if body.papers: + context_parts.append( + f"Papers:\n{json.dumps(body.papers[:20], indent=2)}" + ) + + system_prompt = ( + "You are ScholarAI, an expert academic research assistant specialized in " + "research trend analysis and bibliometrics. Identify emerging trends, " + "declining areas, hot topics, and research trajectories in academic literature." + ) + user_prompt = ( + f"Identify research trends from the following information:\n\n" + f"{chr(10).join(context_parts)}\n\n" + "Please provide:\n" + "1. Emerging research trends and hot topics\n" + "2. Declining or saturated research areas\n" + "3. Methodological trends and shifts\n" + "4. Key breakthroughs and milestone papers\n" + "5. Future research directions\n" + "6. Cross-disciplinary connections and emerging intersections\n" + "7. Technology / tool adoption trends" + ) + result = await run_ai(env, system_prompt, user_prompt) + return {"field": body.field, "trends": result} + + +@app.post("/api/review", summary="Generate a literature review") +async def generate_review(body: ReviewRequest, env=Depends(get_env)): + """ + Generate a structured, academically rigorous literature review section on + a given topic, incorporating provided papers if supplied. + """ + if env is None: + raise HTTPException(status_code=503, detail="AI binding not available") + + context_parts = [ + f"Topic: {body.topic}", + f"Review style: {body.style}", + f"Target audience: {body.audience}", + ] + if body.papers: + context_parts.append( + f"Papers to include:\n{json.dumps(body.papers[:15], indent=2)}" + ) + + system_prompt = ( + "You are ScholarAI, an expert academic research assistant specialized in " + "writing high-quality literature reviews. Generate well-structured, " + "comprehensive, and academically rigorous literature review sections that " + "synthesize research findings." + ) + user_prompt = ( + f"Generate a literature review for the following:\n\n" + f"{chr(10).join(context_parts)}\n\n" + f"Write a {body.style} literature review for {body.audience} that includes:\n" + "1. Introduction to the research area\n" + "2. Historical background and development\n" + "3. Current state of the art\n" + "4. Key methodologies and approaches\n" + "5. Major findings and contributions\n" + "6. Ongoing challenges and open problems\n" + "7. Future research directions\n" + "8. Conclusion" + ) + review = await run_ai(env, system_prompt, user_prompt) + return {"topic": body.topic, "style": body.style, "review": review} + + +# --------------------------------------------------------------------------- +# Cloudflare Workers entrypoint +# --------------------------------------------------------------------------- + + +class Default(WorkerEntrypoint): + async def fetch(self, request): + import asgi + + return await asgi.fetch(app, request.js_object, self.env) diff --git a/tests/__pycache__/test_entry.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_entry.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3d38ea93618170476d4a3c84c1cd1a54670acb46 GIT binary patch literal 37323 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

B{BptqExdS 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 Z dict: # noqa: ARG002 + return {"response": self.DEFAULT_RESPONSE} + + +class MockEnv: + """Minimal stand-in for the Cloudflare Workers env object.""" + + AI = MockAI() + + +# --------------------------------------------------------------------------- +# App fixture with dependency override +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def client(): + """Return a TestClient with the AI env dependency overridden.""" + from src.entry import app, get_env + + mock_env = MockEnv() + app.dependency_overrides[get_env] = lambda: mock_env + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Tests: GET / +# --------------------------------------------------------------------------- + + +def test_api_info_status(client): + response = client.get("/") + assert response.status_code == 200 + + +def test_api_info_name(client): + data = client.get("/").json() + assert data["name"] == "ScholarAI" + assert data["version"] == "1.0.0" + + +def test_api_info_endpoints_listed(client): + data = client.get("/").json() + expected_keys = { + "GET /", + "POST /api/discover", + "POST /api/summarize", + "POST /api/citations", + "POST /api/qa", + "POST /api/organize", + "POST /api/trends", + "POST /api/review", + } + assert expected_keys == set(data["endpoints"].keys()) + + +# --------------------------------------------------------------------------- +# Tests: POST /api/discover +# --------------------------------------------------------------------------- + + +def test_discover_success(client): + response = client.post("/api/discover", json={"query": "transformer models"}) + assert response.status_code == 200 + data = response.json() + assert data["query"] == "transformer models" + assert "results" in data + + +def test_discover_with_fields_and_limit(client): + response = client.post( + "/api/discover", + json={"query": "protein folding", "fields": ["biology", "ML"], "limit": 5}, + ) + assert response.status_code == 200 + assert response.json()["query"] == "protein folding" + + +def test_discover_missing_query(client): + response = client.post("/api/discover", json={}) + assert response.status_code == 422 # FastAPI validation error + + +# --------------------------------------------------------------------------- +# Tests: POST /api/summarize +# --------------------------------------------------------------------------- + + +def test_summarize_with_abstract(client): + response = client.post( + "/api/summarize", + json={"title": "Test Paper", "abstract": "This paper proposes a new method."}, + ) + assert response.status_code == 200 + data = response.json() + assert data["title"] == "Test Paper" + assert "summary" in data + assert len(data["summary"]) > 0 + + +def test_summarize_with_content_only(client): + response = client.post( + "/api/summarize", + json={"content": "Full paper content goes here..."}, + ) + assert response.status_code == 200 + + +def test_summarize_no_input(client): + """All fields empty — endpoint should return 400.""" + response = client.post("/api/summarize", json={}) + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- +# Tests: POST /api/citations +# --------------------------------------------------------------------------- + + +def test_citations_success(client): + response = client.post( + "/api/citations", + json={"paper": "Attention Is All You Need", "type": "related"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["paper"] == "Attention Is All You Need" + assert data["citation_type"] == "related" + assert "exploration" in data + + +def test_citations_missing_paper(client): + response = client.post("/api/citations", json={}) + assert response.status_code == 422 + + +def test_citations_forward_type(client): + response = client.post( + "/api/citations", + json={"paper": "BERT: Pre-training of Deep Bidirectional Transformers", "type": "forward"}, + ) + assert response.status_code == 200 + assert response.json()["citation_type"] == "forward" + + +# --------------------------------------------------------------------------- +# Tests: POST /api/qa +# --------------------------------------------------------------------------- + + +def test_qa_success(client): + response = client.post( + "/api/qa", + json={"question": "What is transfer learning?"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["question"] == "What is transfer learning?" + assert "answer" in data + assert len(data["answer"]) > 0 + + +def test_qa_with_context_and_papers(client): + response = client.post( + "/api/qa", + json={ + "question": "How does BERT work?", + "context": "BERT is a language model.", + "papers": [{"title": "BERT", "year": 2018}], + }, + ) + assert response.status_code == 200 + + +def test_qa_missing_question(client): + response = client.post("/api/qa", json={}) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# Tests: POST /api/organize +# --------------------------------------------------------------------------- + + +def test_organize_success(client): + papers = [ + {"title": "Paper A", "year": 2020}, + {"title": "Paper B", "year": 2021}, + ] + response = client.post( + "/api/organize", + json={"papers": papers, "organize_by": "topic"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["organize_by"] == "topic" + assert "organization" in data + + +def test_organize_missing_papers(client): + response = client.post("/api/organize", json={}) + assert response.status_code == 422 + + +def test_organize_by_year(client): + papers = [{"title": "Paper A", "year": 2019}] + response = client.post( + "/api/organize", json={"papers": papers, "organize_by": "year"} + ) + assert response.status_code == 200 + assert response.json()["organize_by"] == "year" + + +# --------------------------------------------------------------------------- +# Tests: POST /api/trends +# --------------------------------------------------------------------------- + + +def test_trends_with_field(client): + response = client.post("/api/trends", json={"field": "machine learning"}) + assert response.status_code == 200 + data = response.json() + assert data["field"] == "machine learning" + assert "trends" in data + + +def test_trends_with_papers(client): + response = client.post( + "/api/trends", + json={"papers": [{"title": "Paper A", "year": 2023}]}, + ) + assert response.status_code == 200 + + +def test_trends_no_field_or_papers(client): + """Neither field nor papers provided — endpoint should return 400.""" + response = client.post("/api/trends", json={}) + assert response.status_code == 400 + + +def test_trends_with_time_range(client): + response = client.post( + "/api/trends", + json={"field": "NLP", "time_range": "2018-2024"}, + ) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Tests: POST /api/review +# --------------------------------------------------------------------------- + + +def test_review_success(client): + response = client.post( + "/api/review", + json={"topic": "deep learning for NLP"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["topic"] == "deep learning for NLP" + assert data["style"] == "comprehensive" + assert "review" in data + assert len(data["review"]) > 0 + + +def test_review_with_papers_and_style(client): + response = client.post( + "/api/review", + json={ + "topic": "graph neural networks", + "papers": [{"title": "GNN Survey", "year": 2022}], + "style": "brief", + "audience": "graduate students", + }, + ) + assert response.status_code == 200 + assert response.json()["style"] == "brief" + + +def test_review_missing_topic(client): + response = client.post("/api/review", json={}) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# Tests: missing env binding (simulate misconfigured deployment) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client_no_env(): + """Return a TestClient where get_env returns None (no AI binding).""" + from src.entry import app, get_env + + app.dependency_overrides[get_env] = lambda: None + with TestClient(app, raise_server_exceptions=False) as c: + yield c + app.dependency_overrides.clear() + + +def test_discover_no_env(client_no_env): + response = client_no_env.post("/api/discover", json={"query": "test"}) + assert response.status_code == 503 + + +def test_summarize_no_env(client_no_env): + response = client_no_env.post( + "/api/summarize", json={"abstract": "some text"} + ) + assert response.status_code == 503 + + +def test_qa_no_env(client_no_env): + response = client_no_env.post("/api/qa", json={"question": "what is AI?"}) + assert response.status_code == 503 diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..a857abb --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "scholarai", + "main": "src/entry.py", + "compatibility_date": "2025-11-02", + "compatibility_flags": [ + "python_workers", + "python_dedicated_snapshot" + ], + "ai": { + "binding": "AI" + }, + "observability": { + "enabled": true + } +} From 9771be02848e1222b5c4dcf27addad90f4fc2438 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:21:26 +0000 Subject: [PATCH 3/4] Add .gitignore; remove committed __pycache__ artifacts Co-authored-by: A1L13N <193832434+A1L13N@users.noreply.github.com> --- .gitignore | 30 ++++++++++++++++++ .../conftest.cpython-312-pytest-9.0.2.pyc | Bin 1472 -> 0 bytes src/__pycache__/entry.cpython-312.pyc | Bin 18965 -> 0 bytes .../test_entry.cpython-312-pytest-9.0.2.pyc | Bin 37323 -> 0 bytes 4 files changed, 30 insertions(+) create mode 100644 .gitignore delete mode 100644 __pycache__/conftest.cpython-312-pytest-9.0.2.pyc delete mode 100644 src/__pycache__/entry.cpython-312.pyc delete mode 100644 tests/__pycache__/test_entry.cpython-312-pytest-9.0.2.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1525020 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyc +*.pyo +.Python + +# Virtual environments +.venv/ +venv/ +env/ + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# Node / Wrangler +node_modules/ +.wrangler/ +.dev.vars + +# Test / coverage +.pytest_cache/ +.coverage +htmlcov/ + +# Environment variables +.env diff --git a/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 34516b1c871ead4f6a3b0985974aae802a13a89f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1472 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

B{BptqExdS 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 Z Date: Sat, 14 Mar 2026 19:25:10 +0000 Subject: [PATCH 4/4] Switch to wrangler.toml and plain Python Workers; remove FastAPI Co-authored-by: A1L13N <193832434+A1L13N@users.noreply.github.com> --- README.md | 38 +++-- conftest.py | 19 ++- pyproject.toml | 7 +- src/entry.py | 336 +++++++++++++++++----------------------- tests/test_entry.py | 366 +++++++++++++++++++++++--------------------- wrangler.jsonc | 16 -- wrangler.toml | 10 ++ 7 files changed, 385 insertions(+), 407 deletions(-) delete mode 100644 wrangler.jsonc create mode 100644 wrangler.toml diff --git a/README.md b/README.md index aaeb04e..b4a10ae 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ScholarAI -AI-powered research assistant designed to help students and scientists navigate large volumes of academic literature. Supports paper discovery, summarization, citation exploration, and question answering across research papers. Helps organise knowledge, identify trends, and accelerate literature review workflows. +AI-powered research assistant designed to help students and scientists navigate large volumes of academic literature. Supports paper discovery, summarization, citation exploration, and question answering across research papers. Helps organize knowledge, identify trends, and accelerate literature review workflows. -Built as a **Cloudflare Python Worker** powered by **Cloudflare Workers AI** (`@cf/meta/llama-3.1-8b-instruct`). +Built as a **Cloudflare Python Worker** — using **only the `workers` module** (no third-party frameworks) — powered by **Cloudflare Workers AI** (`@cf/meta/llama-3.1-8b-instruct`). --- @@ -11,10 +11,10 @@ Built as a **Cloudflare Python Worker** powered by **Cloudflare Workers AI** (`@ | Feature | Endpoint | Description | |---------|----------|-------------| | Paper discovery | `POST /api/discover` | Find relevant papers for a query | -| Summarisation | `POST /api/summarize` | Structured summary of a paper | +| Summarization | `POST /api/summarize` | Structured summary of a paper | | Citation exploration | `POST /api/citations` | Explore a paper's citation network | | Question answering | `POST /api/qa` | Answer research questions from literature | -| Knowledge organisation | `POST /api/organize` | Cluster and map a reading list | +| Knowledge organization | `POST /api/organize` | Cluster and map a reading list | | Trend identification | `POST /api/trends` | Spot emerging and declining research trends | | Literature review | `POST /api/review` | Generate a full literature review section | @@ -31,15 +31,15 @@ Built as a **Cloudflare Python Worker** powered by **Cloudflare Workers AI** (`@ # Install Node dependencies (Wrangler CLI) npm install -# Start the local development server (requires a free Cloudflare account) +# Authenticate with Cloudflare (required for the Workers AI binding) +npx wrangler login + +# Start the local development server npm run dev ``` The local server starts at `http://localhost:8787`. -> **Note:** A Cloudflare account is required for the Workers AI binding. -> Run `npx wrangler login` before `npm run dev` to authenticate. - --- ## API Reference @@ -126,7 +126,7 @@ Answer a research question using the AI's knowledge and optional context. ### `POST /api/organize` -Organise a reading list into thematic clusters and a knowledge map. +Organize a reading list into thematic clusters and a knowledge map. **Request body:** ```json @@ -202,12 +202,13 @@ uv run pytest tests/ -v ``` scholarai/ ├── src/ -│ └── entry.py # FastAPI app + WorkerEntrypoint +│ └── entry.py # Handler functions + WorkerEntrypoint router ├── tests/ -│ └── test_entry.py # Pytest tests with mocked AI binding -├── wrangler.jsonc # Cloudflare Workers configuration +│ └── test_entry.py # Async pytest tests with mocked AI binding +├── wrangler.toml # Cloudflare Workers configuration ├── pyproject.toml # Python project + dependencies ├── package.json # npm scripts for Wrangler CLI +├── conftest.py # Workers SDK stub for local testing └── README.md ``` @@ -219,18 +220,15 @@ scholarai/ HTTP Request │ ▼ -WorkerEntrypoint.fetch() ← Cloudflare Workers runtime - │ (via asgi bridge) - ▼ -FastAPI app ← routing, validation, error handling - │ +Default.fetch() ← Cloudflare Workers runtime (WorkerEntrypoint) + │ manual URL routing ▼ -Depends(get_env) ← injects Cloudflare env into each handler +handle_*() functions ← pure async business logic │ ▼ -env.AI.run(model, params) ← Cloudflare Workers AI (@cf/meta/llama-3.1-8b-instruct) +env.AI.run(model, params) ← Cloudflare Workers AI (@cf/meta/llama-3.1-8b-instruct) │ ▼ -JSON Response +Response(json, status) ← workers.Response ``` diff --git a/conftest.py b/conftest.py index 9a5c0fe..5b34ab6 100644 --- a/conftest.py +++ b/conftest.py @@ -2,21 +2,34 @@ Pytest configuration: stubs out the Cloudflare Workers SDK so that src/entry.py can be imported in a plain Python test environment. """ +import json import sys from types import ModuleType -from unittest.mock import MagicMock def _make_workers_stub() -> ModuleType: - """Return a minimal 'workers' module stub with WorkerEntrypoint.""" + """Return a minimal 'workers' module stub.""" mod = ModuleType("workers") + class Response: + """Stub for the Cloudflare Workers Response class.""" + + def __init__(self, body="", status=200, headers=None): + self.body = body + self.status = status + self.headers = headers or {} + + def json_body(self) -> dict: + """Convenience helper used in tests to decode the JSON body.""" + return json.loads(self.body) + class WorkerEntrypoint: - """Stub for the Cloudflare Workers SDK WorkerEntrypoint class.""" + """Stub for the Cloudflare Workers WorkerEntrypoint class.""" async def fetch(self, request): # pragma: no cover raise NotImplementedError("Use the Cloudflare Workers runtime") + mod.Response = Response mod.WorkerEntrypoint = WorkerEntrypoint return mod diff --git a/pyproject.toml b/pyproject.toml index 1212c8c..8b2a37f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,6 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "webtypy>=0.1.7", - "fastapi", - "markupsafe", ] [dependency-groups] @@ -15,5 +13,8 @@ dev = [ "workers-py", "workers-runtime-sdk", "pytest", - "httpx", + "pytest-asyncio", ] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/src/entry.py b/src/entry.py index 7b30df5..41c5769 100644 --- a/src/entry.py +++ b/src/entry.py @@ -1,90 +1,31 @@ import json -from typing import Optional +from urllib.parse import urlparse -from fastapi import Depends, FastAPI, HTTPException, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from pydantic import BaseModel -from workers import WorkerEntrypoint +from workers import Response, WorkerEntrypoint # --------------------------------------------------------------------------- -# App setup +# Constants # --------------------------------------------------------------------------- -app = FastAPI( - title="ScholarAI", - description=( - "AI-powered research assistant for students and scientists. " - "Supports paper discovery, summarization, citation exploration, " - "question answering, knowledge organization, trend identification, " - "and literature review generation." - ), - version="1.0.0", -) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - AI_MODEL = "@cf/meta/llama-3.1-8b-instruct" # --------------------------------------------------------------------------- -# Dependency: AI environment -# --------------------------------------------------------------------------- - - -def get_env(request: Request): - """Return the Cloudflare env object injected by the Workers runtime.""" - return request.scope.get("env") - - -# --------------------------------------------------------------------------- -# Request models +# HTTP helpers # --------------------------------------------------------------------------- -class DiscoverRequest(BaseModel): - query: str - fields: list[str] = [] - limit: int = 10 - - -class SummarizeRequest(BaseModel): - title: str = "" - abstract: str = "" - content: str = "" - - -class CitationsRequest(BaseModel): - paper: str - type: str = "related" - - -class QARequest(BaseModel): - question: str - context: str = "" - papers: list[dict] = [] - - -class OrganizeRequest(BaseModel): - papers: list[dict] - organize_by: str = "topic" - - -class TrendsRequest(BaseModel): - field: str = "" - papers: list[dict] = [] - time_range: str = "" +def _json_response(data: dict, status: int = 200) -> Response: + """Return a JSON Response with the correct Content-Type header.""" + return Response( + json.dumps(data), + status=status, + headers={"Content-Type": "application/json"}, + ) -class ReviewRequest(BaseModel): - topic: str - papers: list[dict] = [] - style: str = "comprehensive" - audience: str = "researchers" +def _error(message: str, status: int) -> Response: + """Return a JSON error response.""" + return _json_response({"error": message}, status) # --------------------------------------------------------------------------- @@ -92,7 +33,7 @@ class ReviewRequest(BaseModel): # --------------------------------------------------------------------------- -def _try_parse_json(text: str) -> dict | str: +def _try_parse_json(text: str): """Attempt to extract and parse a JSON object from a text response.""" start = text.find("{") end = text.rfind("}") + 1 @@ -115,20 +56,18 @@ async def run_ai(env, system_prompt: str, user_prompt: str) -> str: ] }, ) - # The llama-3.1 model returns a dict with a "response" key if isinstance(result, dict): return result.get("response", "") - # Newer model objects may expose an attribute return getattr(result, "response", str(result)) # --------------------------------------------------------------------------- -# Routes +# Business-logic handlers (pure async functions, easy to unit-test) # --------------------------------------------------------------------------- -@app.get("/", summary="API information and usage guide") -async def api_info(): +def handle_info() -> dict: + """Return API metadata.""" return { "name": "ScholarAI", "version": "1.0.0", @@ -206,18 +145,16 @@ async def api_info(): } -@app.post("/api/discover", summary="Discover relevant academic papers") -async def discover_papers(body: DiscoverRequest, env=Depends(get_env)): - """ - Discover relevant academic papers for a research query. - Returns suggested papers, research directions, key concepts, and related queries. - """ - if env is None: - raise HTTPException(status_code=503, detail="AI binding not available") +async def handle_discover(body: dict, env) -> tuple[dict, int]: + """Discover relevant academic papers for a research query.""" + query = body.get("query", "").strip() + if not query: + return {"error": "query is required"}, 400 + + fields = body.get("fields", []) + limit = int(body.get("limit", 10)) + field_context = f" in the fields of {', '.join(fields)}" if fields else "" - field_context = ( - f" in the fields of {', '.join(body.fields)}" if body.fields else "" - ) system_prompt = ( "You are ScholarAI, an expert academic research assistant. " "You help researchers discover relevant academic papers and literature. " @@ -227,38 +164,32 @@ async def discover_papers(body: DiscoverRequest, env=Depends(get_env)): ) user_prompt = ( f"Discover academic papers for the following research query{field_context}:\n\n" - f"Query: {body.query}\n" - f"Requested limit: {body.limit} papers\n\n" + f"Query: {query}\n" + f"Requested limit: {limit} papers\n\n" "Return a JSON object with keys: papers (list with title, authors, year, venue, " "abstract_summary, relevance_score, key_topics), research_directions (list), " "key_concepts (list), related_queries (list)." ) response_text = await run_ai(env, system_prompt, user_prompt) - return {"query": body.query, "results": _try_parse_json(response_text)} + return {"query": query, "results": _try_parse_json(response_text)}, 200 -@app.post("/api/summarize", summary="Summarize a research paper") -async def summarize_paper(body: SummarizeRequest, env=Depends(get_env)): - """ - Generate a structured summary of a research paper from its title, abstract, or full content. - """ - if env is None: - raise HTTPException(status_code=503, detail="AI binding not available") +async def handle_summarize(body: dict, env) -> tuple[dict, int]: + """Generate a structured summary of a research paper.""" + title = body.get("title", "").strip() + abstract = body.get("abstract", "").strip() + content = body.get("content", "").strip() - if not (body.title or body.abstract or body.content): - raise HTTPException( - status_code=400, - detail="At least one of title, abstract, or content is required", - ) + if not (title or abstract or content): + return {"error": "At least one of title, abstract, or content is required"}, 400 parts = [] - if body.title: - parts.append(f"Title: {body.title}") - if body.abstract: - parts.append(f"Abstract: {body.abstract}") - if body.content: - # Limit content to avoid exceeding context window - parts.append(f"Content:\n{body.content[:4000]}") + if title: + parts.append(f"Title: {title}") + if abstract: + parts.append(f"Abstract: {abstract}") + if content: + parts.append(f"Content:\n{content[:4000]}") system_prompt = ( "You are ScholarAI, an expert academic research assistant specialized in " @@ -266,7 +197,7 @@ async def summarize_paper(body: SummarizeRequest, env=Depends(get_env)): "that capture the key contributions, methodology, results, and implications." ) user_prompt = ( - f"Summarise the following research paper:\n\n{chr(10).join(parts)}\n\n" + f"Summarize the following research paper:\n\n{chr(10).join(parts)}\n\n" "Structure your summary with these sections:\n" "1. Main contribution\n" "2. Problem being solved\n" @@ -277,17 +208,16 @@ async def summarize_paper(body: SummarizeRequest, env=Depends(get_env)): "7. Impact and significance" ) summary = await run_ai(env, system_prompt, user_prompt) - return {"title": body.title, "summary": summary} + return {"title": title, "summary": summary}, 200 -@app.post("/api/citations", summary="Explore paper citations and related work") -async def explore_citations(body: CitationsRequest, env=Depends(get_env)): - """ - Analyse the citation network around a paper: foundational works, citing papers, - and related research. - """ - if env is None: - raise HTTPException(status_code=503, detail="AI binding not available") +async def handle_citations(body: dict, env) -> tuple[dict, int]: + """Explore the citation network around a paper.""" + paper = body.get("paper", "").strip() + if not paper: + return {"error": "paper is required"}, 400 + + citation_type = body.get("type", "related") system_prompt = ( "You are ScholarAI, an expert academic research assistant specializing in " @@ -297,8 +227,8 @@ async def explore_citations(body: CitationsRequest, env=Depends(get_env)): ) user_prompt = ( f"Explore citations and related work for the following paper:\n\n" - f"Paper: {body.paper}\n" - f"Citation type requested: {body.type}\n\n" + f"Paper: {paper}\n" + f"Citation type requested: {citation_type}\n\n" "Please provide:\n" "1. Key foundational papers (seminal works this paper builds upon)\n" "2. Papers that cite this work (if applicable)\n" @@ -308,25 +238,23 @@ async def explore_citations(body: CitationsRequest, env=Depends(get_env)): "6. Connections to adjacent research fields" ) result = await run_ai(env, system_prompt, user_prompt) - return {"paper": body.paper, "citation_type": body.type, "exploration": result} + return {"paper": paper, "citation_type": citation_type, "exploration": result}, 200 + +async def handle_qa(body: dict, env) -> tuple[dict, int]: + """Answer a research question using the AI.""" + question = body.get("question", "").strip() + if not question: + return {"error": "question is required"}, 400 -@app.post("/api/qa", summary="Question answering about research topics") -async def question_answering(body: QARequest, env=Depends(get_env)): - """ - Answer a research question, drawing on provided context or papers and the - model's knowledge of the scientific literature. - """ - if env is None: - raise HTTPException(status_code=503, detail="AI binding not available") + context = body.get("context", "") + papers = body.get("papers", []) context_parts = [] - if body.context: - context_parts.append(f"Context:\n{body.context}") - if body.papers: - context_parts.append( - f"Available papers:\n{json.dumps(body.papers[:5], indent=2)}" - ) + if context: + context_parts.append(f"Context:\n{context}") + if papers: + context_parts.append(f"Available papers:\n{json.dumps(papers[:5], indent=2)}") system_prompt = ( "You are ScholarAI, an expert academic research assistant with deep knowledge " @@ -337,7 +265,7 @@ async def question_answering(body: QARequest, env=Depends(get_env)): extra = ("\n\n" + "\n\n".join(context_parts)) if context_parts else "" user_prompt = ( f"Please answer the following research question:\n\n" - f"Question: {body.question}{extra}\n\n" + f"Question: {question}{extra}\n\n" "Provide:\n" "1. A comprehensive answer\n" "2. Key evidence and findings from the literature\n" @@ -346,29 +274,28 @@ async def question_answering(body: QARequest, env=Depends(get_env)): "5. Areas of ongoing debate or uncertainty" ) answer = await run_ai(env, system_prompt, user_prompt) - return {"question": body.question, "answer": answer} + return {"question": question, "answer": answer}, 200 -@app.post("/api/organize", summary="Organise a collection of papers") -async def organize_knowledge(body: OrganizeRequest, env=Depends(get_env)): - """ - Organise a list of papers into thematic clusters, a knowledge map, and a - recommended reading order. - """ - if env is None: - raise HTTPException(status_code=503, detail="AI binding not available") +async def handle_organize(body: dict, env) -> tuple[dict, int]: + """Organize a collection of papers into thematic clusters.""" + papers = body.get("papers") + if not papers: + return {"error": "papers is required"}, 400 + + organize_by = body.get("organize_by", "topic") system_prompt = ( "You are ScholarAI, an expert academic research assistant specialized in " "knowledge organization and taxonomy. Help researchers organize and structure " "their literature collection for better understanding and navigation." ) - papers_json = json.dumps(body.papers[:20], indent=2) + papers_json = json.dumps(papers[:20], indent=2) user_prompt = ( - f"Organise the following research papers by {body.organize_by}:\n\n" + f"Organize the following research papers by {organize_by}:\n\n" f"Papers:\n{papers_json}\n\n" "Please provide:\n" - "1. Organised groupings / clusters\n" + "1. Organized groupings / clusters\n" "2. Key themes and relationships between papers\n" "3. A knowledge map showing connections\n" "4. Recommended reading order\n" @@ -376,32 +303,25 @@ async def organize_knowledge(body: OrganizeRequest, env=Depends(get_env)): "6. Cross-cutting themes and methodologies" ) result = await run_ai(env, system_prompt, user_prompt) - return {"organize_by": body.organize_by, "organization": result} + return {"organize_by": organize_by, "organization": result}, 200 -@app.post("/api/trends", summary="Identify research trends in a field") -async def identify_trends(body: TrendsRequest, env=Depends(get_env)): - """ - Identify emerging trends, hot topics, declining areas, and future directions - in a research field or from a set of papers. - """ - if env is None: - raise HTTPException(status_code=503, detail="AI binding not available") +async def handle_trends(body: dict, env) -> tuple[dict, int]: + """Identify research trends in a field or from a set of papers.""" + field = body.get("field", "").strip() + papers = body.get("papers", []) - if not (body.field or body.papers): - raise HTTPException( - status_code=400, detail="At least one of field or papers is required" - ) + if not field and not papers: + return {"error": "At least one of field or papers is required"}, 400 + time_range = body.get("time_range", "") context_parts = [] - if body.field: - context_parts.append(f"Research field: {body.field}") - if body.time_range: - context_parts.append(f"Time range: {body.time_range}") - if body.papers: - context_parts.append( - f"Papers:\n{json.dumps(body.papers[:20], indent=2)}" - ) + if field: + context_parts.append(f"Research field: {field}") + if time_range: + context_parts.append(f"Time range: {time_range}") + if papers: + context_parts.append(f"Papers:\n{json.dumps(papers[:20], indent=2)}") system_prompt = ( "You are ScholarAI, an expert academic research assistant specialized in " @@ -421,27 +341,26 @@ async def identify_trends(body: TrendsRequest, env=Depends(get_env)): "7. Technology / tool adoption trends" ) result = await run_ai(env, system_prompt, user_prompt) - return {"field": body.field, "trends": result} + return {"field": field, "trends": result}, 200 + +async def handle_review(body: dict, env) -> tuple[dict, int]: + """Generate a structured literature review.""" + topic = body.get("topic", "").strip() + if not topic: + return {"error": "topic is required"}, 400 -@app.post("/api/review", summary="Generate a literature review") -async def generate_review(body: ReviewRequest, env=Depends(get_env)): - """ - Generate a structured, academically rigorous literature review section on - a given topic, incorporating provided papers if supplied. - """ - if env is None: - raise HTTPException(status_code=503, detail="AI binding not available") + papers = body.get("papers", []) + style = body.get("style", "comprehensive") + audience = body.get("audience", "researchers") context_parts = [ - f"Topic: {body.topic}", - f"Review style: {body.style}", - f"Target audience: {body.audience}", + f"Topic: {topic}", + f"Review style: {style}", + f"Target audience: {audience}", ] - if body.papers: - context_parts.append( - f"Papers to include:\n{json.dumps(body.papers[:15], indent=2)}" - ) + if papers: + context_parts.append(f"Papers to include:\n{json.dumps(papers[:15], indent=2)}") system_prompt = ( "You are ScholarAI, an expert academic research assistant specialized in " @@ -452,7 +371,7 @@ async def generate_review(body: ReviewRequest, env=Depends(get_env)): user_prompt = ( f"Generate a literature review for the following:\n\n" f"{chr(10).join(context_parts)}\n\n" - f"Write a {body.style} literature review for {body.audience} that includes:\n" + f"Write a {style} literature review for {audience} that includes:\n" "1. Introduction to the research area\n" "2. Historical background and development\n" "3. Current state of the art\n" @@ -463,9 +382,23 @@ async def generate_review(body: ReviewRequest, env=Depends(get_env)): "8. Conclusion" ) review = await run_ai(env, system_prompt, user_prompt) - return {"topic": body.topic, "style": body.style, "review": review} + return {"topic": topic, "style": style, "review": review}, 200 +# --------------------------------------------------------------------------- +# Route table +# --------------------------------------------------------------------------- + +_POST_ROUTES = { + "/api/discover": handle_discover, + "/api/summarize": handle_summarize, + "/api/citations": handle_citations, + "/api/qa": handle_qa, + "/api/organize": handle_organize, + "/api/trends": handle_trends, + "/api/review": handle_review, +} + # --------------------------------------------------------------------------- # Cloudflare Workers entrypoint # --------------------------------------------------------------------------- @@ -473,6 +406,23 @@ async def generate_review(body: ReviewRequest, env=Depends(get_env)): class Default(WorkerEntrypoint): async def fetch(self, request): - import asgi + path = urlparse(request.url).path.rstrip("/") or "/" + method = request.method.upper() + + if method == "GET" and path == "/": + return _json_response(handle_info()) + + if method == "POST": + handler = _POST_ROUTES.get(path) + if handler is None: + return _error("Not found", 404) + + try: + body = await request.json() + except Exception: + return _error("Invalid JSON body", 400) + + data, status = await handler(body, self.env) + return _json_response(data, status) - return await asgi.fetch(app, request.js_object, self.env) + return _error("Not found", 404) diff --git a/tests/test_entry.py b/tests/test_entry.py index b6d0868..ffd1f7c 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -1,13 +1,11 @@ """ -Unit tests for ScholarAI FastAPI endpoints. +Unit tests for ScholarAI. -The Cloudflare Workers AI binding (`env.AI`) is mocked so that tests run -without a live Workers runtime. FastAPI dependency overrides inject the mock -into every endpoint that calls `Depends(get_env)`. +Handler functions from src.entry are called directly with a mocked Cloudflare +env so that tests run without a live Workers runtime. """ import json import pytest -from fastapi.testclient import TestClient # --------------------------------------------------------------------------- # Mock Cloudflare AI binding @@ -29,42 +27,26 @@ class MockEnv: AI = MockAI() -# --------------------------------------------------------------------------- -# App fixture with dependency override -# --------------------------------------------------------------------------- - - -@pytest.fixture(scope="module") -def client(): - """Return a TestClient with the AI env dependency overridden.""" - from src.entry import app, get_env - - mock_env = MockEnv() - app.dependency_overrides[get_env] = lambda: mock_env - with TestClient(app) as c: - yield c - app.dependency_overrides.clear() - +ENV = MockEnv() # --------------------------------------------------------------------------- -# Tests: GET / +# Tests: handle_info # --------------------------------------------------------------------------- -def test_api_info_status(client): - response = client.get("/") - assert response.status_code == 200 - +def test_handle_info_name(): + from src.entry import handle_info -def test_api_info_name(client): - data = client.get("/").json() + data = handle_info() assert data["name"] == "ScholarAI" assert data["version"] == "1.0.0" -def test_api_info_endpoints_listed(client): - data = client.get("/").json() - expected_keys = { +def test_handle_info_endpoints_listed(): + from src.entry import handle_info + + data = handle_info() + expected = { "GET /", "POST /api/discover", "POST /api/summarize", @@ -74,266 +56,306 @@ def test_api_info_endpoints_listed(client): "POST /api/trends", "POST /api/review", } - assert expected_keys == set(data["endpoints"].keys()) + assert expected == set(data["endpoints"].keys()) # --------------------------------------------------------------------------- -# Tests: POST /api/discover +# Tests: handle_discover # --------------------------------------------------------------------------- -def test_discover_success(client): - response = client.post("/api/discover", json={"query": "transformer models"}) - assert response.status_code == 200 - data = response.json() +@pytest.mark.asyncio +async def test_discover_success(): + from src.entry import handle_discover + + data, status = await handle_discover({"query": "transformer models"}, ENV) + assert status == 200 assert data["query"] == "transformer models" assert "results" in data -def test_discover_with_fields_and_limit(client): - response = client.post( - "/api/discover", - json={"query": "protein folding", "fields": ["biology", "ML"], "limit": 5}, +@pytest.mark.asyncio +async def test_discover_with_fields_and_limit(): + from src.entry import handle_discover + + data, status = await handle_discover( + {"query": "protein folding", "fields": ["biology", "ML"], "limit": 5}, ENV ) - assert response.status_code == 200 - assert response.json()["query"] == "protein folding" + assert status == 200 + assert data["query"] == "protein folding" + +@pytest.mark.asyncio +async def test_discover_missing_query(): + from src.entry import handle_discover -def test_discover_missing_query(client): - response = client.post("/api/discover", json={}) - assert response.status_code == 422 # FastAPI validation error + data, status = await handle_discover({}, ENV) + assert status == 400 + assert "error" in data # --------------------------------------------------------------------------- -# Tests: POST /api/summarize +# Tests: handle_summarize # --------------------------------------------------------------------------- -def test_summarize_with_abstract(client): - response = client.post( - "/api/summarize", - json={"title": "Test Paper", "abstract": "This paper proposes a new method."}, +@pytest.mark.asyncio +async def test_summarize_with_abstract(): + from src.entry import handle_summarize + + data, status = await handle_summarize( + {"title": "Test Paper", "abstract": "This paper proposes a new method."}, ENV ) - assert response.status_code == 200 - data = response.json() + assert status == 200 assert data["title"] == "Test Paper" assert "summary" in data assert len(data["summary"]) > 0 -def test_summarize_with_content_only(client): - response = client.post( - "/api/summarize", - json={"content": "Full paper content goes here..."}, +@pytest.mark.asyncio +async def test_summarize_with_content_only(): + from src.entry import handle_summarize + + data, status = await handle_summarize( + {"content": "Full paper content goes here..."}, ENV ) - assert response.status_code == 200 + assert status == 200 + +@pytest.mark.asyncio +async def test_summarize_no_input(): + from src.entry import handle_summarize -def test_summarize_no_input(client): - """All fields empty — endpoint should return 400.""" - response = client.post("/api/summarize", json={}) - assert response.status_code == 400 + data, status = await handle_summarize({}, ENV) + assert status == 400 + assert "error" in data # --------------------------------------------------------------------------- -# Tests: POST /api/citations +# Tests: handle_citations # --------------------------------------------------------------------------- -def test_citations_success(client): - response = client.post( - "/api/citations", - json={"paper": "Attention Is All You Need", "type": "related"}, +@pytest.mark.asyncio +async def test_citations_success(): + from src.entry import handle_citations + + data, status = await handle_citations( + {"paper": "Attention Is All You Need", "type": "related"}, ENV ) - assert response.status_code == 200 - data = response.json() + assert status == 200 assert data["paper"] == "Attention Is All You Need" assert data["citation_type"] == "related" assert "exploration" in data -def test_citations_missing_paper(client): - response = client.post("/api/citations", json={}) - assert response.status_code == 422 +@pytest.mark.asyncio +async def test_citations_missing_paper(): + from src.entry import handle_citations + + data, status = await handle_citations({}, ENV) + assert status == 400 + assert "error" in data -def test_citations_forward_type(client): - response = client.post( - "/api/citations", - json={"paper": "BERT: Pre-training of Deep Bidirectional Transformers", "type": "forward"}, +@pytest.mark.asyncio +async def test_citations_forward_type(): + from src.entry import handle_citations + + data, status = await handle_citations( + {"paper": "BERT: Pre-training of Deep Bidirectional Transformers", "type": "forward"}, + ENV, ) - assert response.status_code == 200 - assert response.json()["citation_type"] == "forward" + assert status == 200 + assert data["citation_type"] == "forward" # --------------------------------------------------------------------------- -# Tests: POST /api/qa +# Tests: handle_qa # --------------------------------------------------------------------------- -def test_qa_success(client): - response = client.post( - "/api/qa", - json={"question": "What is transfer learning?"}, - ) - assert response.status_code == 200 - data = response.json() +@pytest.mark.asyncio +async def test_qa_success(): + from src.entry import handle_qa + + data, status = await handle_qa({"question": "What is transfer learning?"}, ENV) + assert status == 200 assert data["question"] == "What is transfer learning?" assert "answer" in data assert len(data["answer"]) > 0 -def test_qa_with_context_and_papers(client): - response = client.post( - "/api/qa", - json={ +@pytest.mark.asyncio +async def test_qa_with_context_and_papers(): + from src.entry import handle_qa + + data, status = await handle_qa( + { "question": "How does BERT work?", "context": "BERT is a language model.", "papers": [{"title": "BERT", "year": 2018}], }, + ENV, ) - assert response.status_code == 200 + assert status == 200 + +@pytest.mark.asyncio +async def test_qa_missing_question(): + from src.entry import handle_qa -def test_qa_missing_question(client): - response = client.post("/api/qa", json={}) - assert response.status_code == 422 + data, status = await handle_qa({}, ENV) + assert status == 400 + assert "error" in data # --------------------------------------------------------------------------- -# Tests: POST /api/organize +# Tests: handle_organize # --------------------------------------------------------------------------- -def test_organize_success(client): - papers = [ - {"title": "Paper A", "year": 2020}, - {"title": "Paper B", "year": 2021}, - ] - response = client.post( - "/api/organize", - json={"papers": papers, "organize_by": "topic"}, - ) - assert response.status_code == 200 - data = response.json() +@pytest.mark.asyncio +async def test_organize_success(): + from src.entry import handle_organize + + papers = [{"title": "Paper A", "year": 2020}, {"title": "Paper B", "year": 2021}] + data, status = await handle_organize({"papers": papers, "organize_by": "topic"}, ENV) + assert status == 200 assert data["organize_by"] == "topic" assert "organization" in data -def test_organize_missing_papers(client): - response = client.post("/api/organize", json={}) - assert response.status_code == 422 +@pytest.mark.asyncio +async def test_organize_missing_papers(): + from src.entry import handle_organize + data, status = await handle_organize({}, ENV) + assert status == 400 + assert "error" in data -def test_organize_by_year(client): - papers = [{"title": "Paper A", "year": 2019}] - response = client.post( - "/api/organize", json={"papers": papers, "organize_by": "year"} + +@pytest.mark.asyncio +async def test_organize_by_year(): + from src.entry import handle_organize + + data, status = await handle_organize( + {"papers": [{"title": "Paper A", "year": 2019}], "organize_by": "year"}, ENV ) - assert response.status_code == 200 - assert response.json()["organize_by"] == "year" + assert status == 200 + assert data["organize_by"] == "year" # --------------------------------------------------------------------------- -# Tests: POST /api/trends +# Tests: handle_trends # --------------------------------------------------------------------------- -def test_trends_with_field(client): - response = client.post("/api/trends", json={"field": "machine learning"}) - assert response.status_code == 200 - data = response.json() +@pytest.mark.asyncio +async def test_trends_with_field(): + from src.entry import handle_trends + + data, status = await handle_trends({"field": "machine learning"}, ENV) + assert status == 200 assert data["field"] == "machine learning" assert "trends" in data -def test_trends_with_papers(client): - response = client.post( - "/api/trends", - json={"papers": [{"title": "Paper A", "year": 2023}]}, +@pytest.mark.asyncio +async def test_trends_with_papers(): + from src.entry import handle_trends + + data, status = await handle_trends( + {"papers": [{"title": "Paper A", "year": 2023}]}, ENV ) - assert response.status_code == 200 + assert status == 200 -def test_trends_no_field_or_papers(client): - """Neither field nor papers provided — endpoint should return 400.""" - response = client.post("/api/trends", json={}) - assert response.status_code == 400 +@pytest.mark.asyncio +async def test_trends_no_field_or_papers(): + from src.entry import handle_trends + data, status = await handle_trends({}, ENV) + assert status == 400 + assert "error" in data -def test_trends_with_time_range(client): - response = client.post( - "/api/trends", - json={"field": "NLP", "time_range": "2018-2024"}, - ) - assert response.status_code == 200 + +@pytest.mark.asyncio +async def test_trends_with_time_range(): + from src.entry import handle_trends + + data, status = await handle_trends({"field": "NLP", "time_range": "2018-2024"}, ENV) + assert status == 200 # --------------------------------------------------------------------------- -# Tests: POST /api/review +# Tests: handle_review # --------------------------------------------------------------------------- -def test_review_success(client): - response = client.post( - "/api/review", - json={"topic": "deep learning for NLP"}, - ) - assert response.status_code == 200 - data = response.json() +@pytest.mark.asyncio +async def test_review_success(): + from src.entry import handle_review + + data, status = await handle_review({"topic": "deep learning for NLP"}, ENV) + assert status == 200 assert data["topic"] == "deep learning for NLP" assert data["style"] == "comprehensive" assert "review" in data assert len(data["review"]) > 0 -def test_review_with_papers_and_style(client): - response = client.post( - "/api/review", - json={ +@pytest.mark.asyncio +async def test_review_with_papers_and_style(): + from src.entry import handle_review + + data, status = await handle_review( + { "topic": "graph neural networks", "papers": [{"title": "GNN Survey", "year": 2022}], "style": "brief", "audience": "graduate students", }, + ENV, ) - assert response.status_code == 200 - assert response.json()["style"] == "brief" + assert status == 200 + assert data["style"] == "brief" + +@pytest.mark.asyncio +async def test_review_missing_topic(): + from src.entry import handle_review -def test_review_missing_topic(client): - response = client.post("/api/review", json={}) - assert response.status_code == 422 + data, status = await handle_review({}, ENV) + assert status == 400 + assert "error" in data # --------------------------------------------------------------------------- -# Tests: missing env binding (simulate misconfigured deployment) +# Tests: _json_response and _error helpers # --------------------------------------------------------------------------- -@pytest.fixture -def client_no_env(): - """Return a TestClient where get_env returns None (no AI binding).""" - from src.entry import app, get_env +def test_json_response_content_type(): + from src.entry import _json_response - app.dependency_overrides[get_env] = lambda: None - with TestClient(app, raise_server_exceptions=False) as c: - yield c - app.dependency_overrides.clear() + resp = _json_response({"key": "value"}) + assert resp.headers.get("Content-Type") == "application/json" + assert resp.status == 200 + assert json.loads(resp.body) == {"key": "value"} -def test_discover_no_env(client_no_env): - response = client_no_env.post("/api/discover", json={"query": "test"}) - assert response.status_code == 503 +def test_json_response_custom_status(): + from src.entry import _json_response + resp = _json_response({"error": "bad"}, status=400) + assert resp.status == 400 -def test_summarize_no_env(client_no_env): - response = client_no_env.post( - "/api/summarize", json={"abstract": "some text"} - ) - assert response.status_code == 503 +def test_error_helper(): + from src.entry import _error + + resp = _error("Not found", 404) + assert resp.status == 404 + assert json.loads(resp.body) == {"error": "Not found"} -def test_qa_no_env(client_no_env): - response = client_no_env.post("/api/qa", json={"question": "what is AI?"}) - assert response.status_code == 503 diff --git a/wrangler.jsonc b/wrangler.jsonc deleted file mode 100644 index a857abb..0000000 --- a/wrangler.jsonc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "scholarai", - "main": "src/entry.py", - "compatibility_date": "2025-11-02", - "compatibility_flags": [ - "python_workers", - "python_dedicated_snapshot" - ], - "ai": { - "binding": "AI" - }, - "observability": { - "enabled": true - } -} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..d2a0606 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,10 @@ +name = "scholarai" +main = "src/entry.py" +compatibility_date = "2025-11-02" +compatibility_flags = ["python_workers"] + +[ai] +binding = "AI" + +[observability] +enabled = true