Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Command mode:
```bash
canvas-github-agent list-courses
canvas-github-agent list-assignments --course-id 12345
canvas-github-agent list-modules --course-id 12345
canvas-github-agent create-repo --course-id 12345
canvas-github-agent create-repo --course-id 12345 --assignment-id 67890
canvas-github-agent create-repo --course-id 12345 --language r
Expand All @@ -86,6 +87,7 @@ canvas-github-agent create-repo --course-id 12345 --confirm-type
canvas-github-agent ingest-pdf --course-id 12345 --file-path "docs/AAI6660_Spring_2026 (1).pdf"
canvas-github-agent list-documents --course-id 12345
canvas-github-agent search-context --course-id 12345 --query "Bayes theorem posterior update"
canvas-github-agent search-modules --course-id 12345 --query "Bayes theorem posterior update"
```

## API Endpoints
Expand All @@ -94,6 +96,8 @@ canvas-github-agent search-context --course-id 12345 --query "Bayes theorem post
- GET /capabilities
- GET /courses
- GET /courses/{course_id}/assignments
- GET /courses/{course_id}/modules
- POST /courses/{course_id}/modules/search
- POST /courses/{course_id}/documents/ingest
- GET /courses/{course_id}/documents
- POST /courses/{course_id}/context/search
Expand All @@ -106,7 +110,7 @@ The `/create` endpoint returns a stable `task_result_v1` payload with service, r

The `/tasks` endpoints expose an asynchronous `task_status_v1` lifecycle with `queued`, `running`, `completed`, and `failed` states.

Course PDFs can be ingested with Docling and indexed into a local Chroma store. During assignment creation, the app will search that indexed course context and attach the most relevant excerpts to generated outputs.
Course PDFs can be ingested with Docling and indexed into a local Chroma store. During assignment creation, the app will search both indexed course documents and live Canvas module content, then attach the most relevant excerpts to generated outputs.

## MCP Server

Expand All @@ -122,6 +126,8 @@ Primary MCP tools:

- `list_courses`
- `list_assignments`
- `list_course_modules`
- `search_course_modules`
- `get_capabilities`
- `get_oasf_record`
- `ingest_course_document`
Expand Down Expand Up @@ -150,11 +156,15 @@ Claude Desktop expects absolute paths. Update the template paths and env values,

## Course Context

The repository now supports a local Chroma-backed retrieval store for course reference material such as slide decks.
The repository now supports two deterministic course-context sources:

- Use Docling to parse the PDF into markdown-like text chunks
- live Canvas module content retrieved through the Canvas API
- a local Chroma-backed retrieval store for reference material such as slide decks

- Read module pages, assignments, and discussion topics from Canvas and rank the most relevant excerpts against the assignment text
- Use Docling to parse PDFs into markdown-like text chunks
- Store those chunks in Chroma with course-scoped metadata
- Retrieve relevant excerpts during assignment creation so generated repos and pages can reference the slide deck
- Retrieve relevant excerpts during assignment creation so generated repos and pages can reference both Canvas modules and uploaded course materials

Local Chroma data is stored under `.chroma/` by default and is ignored by git.

Expand Down
2 changes: 2 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# TODO

- Fix the CLI argument validation in `app/agent.py` so `search-context --course-id 0` is accepted instead of being treated as missing.
46 changes: 46 additions & 0 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ def build_capabilities_payload() -> dict[str, Any]:
"path": "/courses/{course_id}/assignments",
"description": "List assignments for a Canvas course.",
},
{
"name": "list_course_modules",
"method": "GET",
"path": "/courses/{course_id}/modules",
"description": "List Canvas modules and module items for a course.",
},
{
"name": "search_course_modules",
"method": "POST",
"path": "/courses/{course_id}/modules/search",
"description": "Search Canvas course module content for assignment-relevant context.",
},
{
"name": "get_oasf_record",
"method": "GET",
Expand Down Expand Up @@ -148,6 +160,7 @@ def build_capabilities_payload() -> dict[str, Any]:
"supported_languages": ["python", "r"],
"course_context_backend": "chroma",
"course_context_parser": "docling",
"course_context_sources": ["canvas_modules", "chroma_documents"],
},
"result_schema": {
"name": TASK_RESULT_SCHEMA,
Expand Down Expand Up @@ -433,6 +446,39 @@ async def get_assignments(course_id: int):
raise HTTPException(status_code=500, detail="Failed to fetch assignments.")


@app.get("/courses/{course_id}/modules")
async def get_modules(course_id: int):
"""Return Canvas modules for a given course."""
try:
canvas = CanvasTools()
modules = await canvas.get_course_modules(course_id)
return {"modules": modules}
except HTTPException:
raise
except Exception:
logger.exception("Failed to list modules for course_id=%s", course_id)
raise HTTPException(status_code=500, detail="Failed to fetch modules.")


@app.post("/courses/{course_id}/modules/search")
async def search_course_modules(course_id: int, req: CourseContextSearchRequest):
"""Search Canvas course modules for assignment-relevant context."""
try:
canvas = CanvasTools()
results = await canvas.search_course_module_context(course_id, req.query, req.limit)
return {
"course_id": course_id,
"query": req.query,
"limit": req.limit,
"results": results,
}
except HTTPException:
raise
except Exception:
logger.exception("Failed to search modules for course_id=%s", course_id)
raise HTTPException(status_code=500, detail="Failed to search course modules.")


@app.post("/courses/{course_id}/documents/ingest")
async def ingest_course_document(course_id: int, req: CourseDocumentIngestRequest):
"""Parse a local course PDF and index it into Chroma."""
Expand Down
4 changes: 4 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
ingest_course_pdf,
list_course_assignments,
list_course_documents,
list_course_modules,
list_courses,
main,
search_course_context,
search_course_modules,
)
from .cli import interactive_mode, print_usage

Expand All @@ -18,7 +20,9 @@
"list_courses",
"list_course_assignments",
"list_course_documents",
"list_course_modules",
"main",
"print_usage",
"search_course_context",
"search_course_modules",
]
97 changes: 90 additions & 7 deletions app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,32 @@ def validate_notion_config(self) -> list[str]:
"""Return missing Notion environment variables required for writing flow."""
return get_missing_notion_config()

@staticmethod
def build_context_query(assignment: dict) -> str:
"""Build a retrieval query from assignment name and description."""
assignment_name = assignment.get("name", "").strip()
assignment_description = CanvasGitHubAgent.strip_html(assignment.get("description", ""))
return "\n\n".join(part for part in [assignment_name, assignment_description] if part).strip()

@staticmethod
def merge_course_context_sources(
document_context: Sequence[dict],
module_context: Sequence[dict],
limit: int = 5,
) -> list[dict[str, Any]]:
"""Interleave document and module context so both sources can contribute."""
merged: list[dict[str, Any]] = []
document_items = list(document_context)
module_items = list(module_context)

while len(merged) < max(limit, 1) and (document_items or module_items):
if document_items and len(merged) < limit:
merged.append(document_items.pop(0))
if module_items and len(merged) < limit:
merged.append(module_items.pop(0))

return merged[: max(limit, 1)]

async def fetch_assignment(self, course_id: int, assignment_id: Optional[int] = None) -> dict:
"""Fetch assignment details from Canvas."""
if assignment_id:
Expand Down Expand Up @@ -243,15 +269,16 @@ async def create_notion_page_for_assignment_with_mode(
return {"page": page, "assignment": assignment, "course_context": list(course_context or [])}

async def fetch_course_context(self, course_id: int, assignment: dict, limit: int = 5) -> list[dict[str, Any]]:
"""Retrieve relevant course-document chunks for the given assignment."""
assignment_name = assignment.get("name", "").strip()
assignment_description = self.strip_html(assignment.get("description", ""))
query = "\n\n".join(part for part in [assignment_name, assignment_description] if part).strip()
"""Retrieve relevant course-document and module context for the given assignment."""
query = self.build_context_query(assignment)
if not query:
return []

document_context: list[dict[str, Any]] = []
module_context: list[dict[str, Any]] = []

try:
results = await asyncio.to_thread(
document_context = await asyncio.to_thread(
self.course_context_tools.search_context,
course_id,
query,
Expand All @@ -262,10 +289,27 @@ async def fetch_course_context(self, course_id: int, assignment: dict, limit: in
return []
except Exception as error:
print(f"\n⚠️ Course context search failed: {error}")
return []
document_context = []

try:
module_context = await self.canvas_tools.search_course_module_context(
course_id,
query,
limit,
)
except Exception as error:
print(f"\n⚠️ Course module search failed: {error}")
module_context = []

if document_context:
print(f"\n📎 Retrieved {len(document_context)} course document matches from your indexed course materials.")
if module_context:
print(f"\n📚 Retrieved {len(module_context)} course module matches from Canvas.")

results = self.merge_course_context_sources(document_context, module_context, limit)

if results:
print(f"\n📎 Retrieved {len(results)} course context matches from your course documents.")
print(f"\n🧩 Using {len(results)} combined course context matches to support this assignment.")
return results

async def run(
Expand Down Expand Up @@ -411,6 +455,33 @@ async def list_course_documents(course_id: int):
)


async def list_course_modules(course_id: int):
"""List Canvas modules available for a course."""
tools = CanvasTools()
modules = await tools.get_course_modules(course_id)

print(f"\n📚 Canvas modules for course {course_id}")
print("-" * 80)
for module in modules:
print(f"{module['name']} | items={len(module.get('items', []))}")


async def search_course_modules(course_id: int, query: str, limit: int = 5):
"""Search Canvas module content for assignment-relevant context."""
tools = CanvasTools()
results = await tools.search_course_module_context(course_id, query, limit)

print(f"\n🔎 Retrieved {len(results)} module context matches")
print("-" * 80)
for index, item in enumerate(results, start=1):
print(f"{index}. {item.get('section_title') or item.get('module_name') or 'Match'}")
print(f" Source: {item.get('document_name', 'Canvas Module')}")
print(f" Type: {item.get('item_type', 'Unknown')}")
if item.get("distance") is not None:
print(f" Distance: {item['distance']:.4f}")
print(f" {item.get('text', '')[:400]}\n")


async def list_courses():
"""Helper function to list available courses."""
canvas_tools = CanvasTools()
Expand Down Expand Up @@ -463,10 +534,12 @@ async def main():
choices=[
"list-courses",
"list-assignments",
"list-modules",
"create-repo",
"ingest-pdf",
"list-documents",
"search-context",
"search-modules",
],
help="Command to execute",
)
Expand Down Expand Up @@ -506,6 +579,11 @@ async def main():
print("Error: --course-id is required for list-assignments")
return
await list_course_assignments(args.course_id)
elif args.command == "list-modules":
if args.course_id is None:
print("Error: --course-id is required for list-modules")
return
await list_course_modules(args.course_id)
elif args.command == "create-repo":
if not args.course_id:
print("Error: --course-id is required for create-repo")
Expand Down Expand Up @@ -534,6 +612,11 @@ async def main():
print("Error: --course-id and --query are required for search-context")
return
await search_course_context(args.course_id, args.query, args.limit)
elif args.command == "search-modules":
if args.course_id is None or not args.query:
print("Error: --course-id and --query are required for search-modules")
return
await search_course_modules(args.course_id, args.query, args.limit)


def run() -> None:
Expand Down
21 changes: 21 additions & 0 deletions app/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ async def list_assignments(course_id: int) -> dict[str, Any]:
_raise_tool_error(exc)


@server.tool(description="List Canvas modules and module items for a course.")
async def list_course_modules(course_id: int) -> dict[str, Any]:
"""Return Canvas modules for a course."""
try:
return await api.get_modules(course_id)
except HTTPException as exc:
_raise_tool_error(exc)


@server.tool(description="Search Canvas course module content for assignment-relevant context.")
async def search_course_modules(course_id: int, query: str, limit: int = 5) -> dict[str, Any]:
"""Search Canvas module content for relevant context."""
try:
return await api.search_course_modules(
course_id,
api.CourseContextSearchRequest(query=query, limit=limit),
)
except HTTPException as exc:
_raise_tool_error(exc)


@server.tool(description="Return the service capabilities payload used for discovery.")
async def get_capabilities() -> dict[str, Any]:
"""Return service capabilities and transport metadata."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"list_assignments",
"fetch_assignment",
"infer_assignment_type",
"list_course_modules",
"search_course_modules",
"ingest_course_document",
"search_course_context",
"create_github_repository",
Expand Down Expand Up @@ -59,6 +61,10 @@
],
"course_context_backend": "chroma",
"course_context_parser": "docling",
"course_context_sources": [
"canvas_modules",
"chroma_documents"
],
"supported_course_document_formats": [
"pdf"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
"course_context_ingest_endpoint": "http://localhost:8000/courses/{course_id}/documents/ingest",
"course_context_parser": "docling",
"course_context_search_endpoint": "http://localhost:8000/courses/{course_id}/context/search",
"course_context_sources": "canvas_modules,chroma_documents",
"course_module_listing_endpoint": "http://localhost:8000/courses/{course_id}/modules",
"course_module_search_endpoint": "http://localhost:8000/courses/{course_id}/modules/search",
"entrypoint": "app/agent.py",
"health_endpoint": "http://localhost:8000/health",
"invocation_mode": "supports synchronous request-response and asynchronous task polling",
Expand Down
6 changes: 6 additions & 0 deletions scaffolding/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ def build_service_fact_card() -> Dict[str, Any]:
"list_assignments",
"fetch_assignment",
"infer_assignment_type",
"list_course_modules",
"search_course_modules",
"ingest_course_document",
"search_course_context",
"create_github_repository",
Expand Down Expand Up @@ -437,6 +439,7 @@ def build_service_fact_card() -> Dict[str, Any]:
"supported_languages": ["python", "r"],
"course_context_backend": "chroma",
"course_context_parser": "docling",
"course_context_sources": ["canvas_modules", "chroma_documents"],
"supported_course_document_formats": ["pdf"],
"notebook_support": "python notebook scaffolding for assignments that explicitly require Jupyter notebook submission",
},
Expand Down Expand Up @@ -509,6 +512,9 @@ def build_service_oasf_record(service_base_url: Optional[str] = None) -> Dict[st
"course_context_ingest_endpoint": f"{resolved_service_base_url}/courses/{{course_id}}/documents/ingest",
"course_context_parser": "docling",
"course_context_search_endpoint": f"{resolved_service_base_url}/courses/{{course_id}}/context/search",
"course_context_sources": "canvas_modules,chroma_documents",
"course_module_listing_endpoint": f"{resolved_service_base_url}/courses/{{course_id}}/modules",
"course_module_search_endpoint": f"{resolved_service_base_url}/courses/{{course_id}}/modules/search",
"entrypoint": "app/agent.py",
"health_endpoint": f"{resolved_service_base_url}/health",
"invocation_mode": "supports synchronous request-response and asynchronous task polling",
Expand Down
Loading
Loading