Metadata-Version: 2.5
Name: course-harness
Version: 0.4.0
Dynamic: Maintainer
Dynamic: Maintainer-email
Summary: A local-first workspace for authoring source-grounded courses
Project-URL: Homepage, https://github.com/martinkozle/course-harness
Project-URL: Repository, https://github.com/martinkozle/course-harness
Project-URL: Issues, https://github.com/martinkozle/course-harness/issues
License-Expression: MIT
License-File: LICENSE
Keywords: course-authoring,education,local-first,presentations
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Education
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Education
Requires-Python: <3.15,>=3.14
Requires-Dist: docling-slim[convert-core,format-html,format-office,format-pdf]>=2.129
Requires-Dist: fastapi
Requires-Dist: fastmcp-slim[client]>=4
Requires-Dist: httpx2
Requires-Dist: keyring>=25.6
Requires-Dist: packaging
Requires-Dist: pillow>=12.3.0
Requires-Dist: pydantic-ai-slim[ag-ui,anthropic,bedrock,mcp,openai]
Requires-Dist: python-multipart>=0.0.32
Requires-Dist: python-pptx>=1.0.2
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: typer
Requires-Dist: uvicorn[standard]
Provides-Extra: docling
Requires-Dist: docling>=2.129; extra == 'docling'
Requires-Dist: torch; extra == 'docling'
Requires-Dist: torchvision; extra == 'docling'
Provides-Extra: pinned
Requires-Dist: ag-ui-protocol==0.1.19; extra == 'pinned'
Requires-Dist: aiofile==3.12.3; extra == 'pinned'
Requires-Dist: annotated-doc==0.0.4; extra == 'pinned'
Requires-Dist: annotated-types==0.7.0; extra == 'pinned'
Requires-Dist: anthropic==1.14.0; extra == 'pinned'
Requires-Dist: anyio==4.14.2; extra == 'pinned'
Requires-Dist: anyio==4.14.2; (sys_platform != 'emscripten') and extra == 'pinned'
Requires-Dist: attrs==26.1.0; extra == 'pinned'
Requires-Dist: authlib==1.8.0; extra == 'pinned'
Requires-Dist: beartype==0.22.9; extra == 'pinned'
Requires-Dist: beautifulsoup4==4.15.0; extra == 'pinned'
Requires-Dist: boto3==1.43.103; extra == 'pinned'
Requires-Dist: botocore==1.43.103; extra == 'pinned'
Requires-Dist: cachetools==7.2.0; extra == 'pinned'
Requires-Dist: caio==0.12.4; extra == 'pinned'
Requires-Dist: certifi==2026.6.17; extra == 'pinned'
Requires-Dist: cffi==2.1.1; (platform_python_implementation != 'PyPy') and extra == 'pinned'
Requires-Dist: cffi==2.1.1; (sys_platform == 'linux' and platform_python_implementation != 'PyPy') and extra == 'pinned'
Requires-Dist: charset-normalizer==3.4.9; extra == 'pinned'
Requires-Dist: click==8.4.2; extra == 'pinned'
Requires-Dist: click==8.4.2; (sys_platform != 'emscripten') and extra == 'pinned'
Requires-Dist: colorama==0.4.6; (sys_platform != 'emscripten' and sys_platform == 'win32') and extra == 'pinned'
Requires-Dist: colorama==0.4.6; (sys_platform == 'win32') and extra == 'pinned'
Requires-Dist: cryptography==50.0.1; extra == 'pinned'
Requires-Dist: cryptography==50.0.1; (sys_platform == 'linux') and extra == 'pinned'
Requires-Dist: defusedxml==0.7.1; extra == 'pinned'
Requires-Dist: dnspython==2.8.0; extra == 'pinned'
Requires-Dist: doclang==0.7.3; extra == 'pinned'
Requires-Dist: docling-core==2.101.1; extra == 'pinned'
Requires-Dist: docling-parse==7.22.3; extra == 'pinned'
Requires-Dist: docling-slim==2.139.0; extra == 'pinned'
Requires-Dist: docstring-parser==0.18.0; extra == 'pinned'
Requires-Dist: email-validator==2.3.0; extra == 'pinned'
Requires-Dist: et-xmlfile==2.0.0; extra == 'pinned'
Requires-Dist: exceptiongroup==1.3.1; extra == 'pinned'
Requires-Dist: fastapi==0.139.2; extra == 'pinned'
Requires-Dist: fastmcp-slim==4.1.0; extra == 'pinned'
Requires-Dist: filetype==1.2.0; extra == 'pinned'
Requires-Dist: genai-prices==0.1.12; extra == 'pinned'
Requires-Dist: griffelib==2.1.0; extra == 'pinned'
Requires-Dist: h11==0.16.0; extra == 'pinned'
Requires-Dist: h11==0.16.0; (sys_platform != 'emscripten') and extra == 'pinned'
Requires-Dist: httpcore2==2.13.1; (sys_platform != 'emscripten') and extra == 'pinned'
Requires-Dist: httptools==0.8.0; extra == 'pinned'
Requires-Dist: httpx2-jsfetch==1.0; (sys_platform == 'emscripten') and extra == 'pinned'
Requires-Dist: httpx2==2.13.1; extra == 'pinned'
Requires-Dist: idna==3.18; extra == 'pinned'
Requires-Dist: idna==3.18; (sys_platform != 'emscripten') and extra == 'pinned'
Requires-Dist: jaraco-classes==3.4.0; extra == 'pinned'
Requires-Dist: jaraco-context==6.1.2; extra == 'pinned'
Requires-Dist: jaraco-functools==4.6.0; extra == 'pinned'
Requires-Dist: jeepney==0.9.0; (sys_platform == 'linux') and extra == 'pinned'
Requires-Dist: jiter==0.16.0; extra == 'pinned'
Requires-Dist: jmespath==1.1.0; extra == 'pinned'
Requires-Dist: joserfc==1.7.5; extra == 'pinned'
Requires-Dist: jsonref==1.1.0; extra == 'pinned'
Requires-Dist: jsonschema-specifications==2025.9.1; extra == 'pinned'
Requires-Dist: jsonschema==4.26.0; extra == 'pinned'
Requires-Dist: keyring==25.7.0; extra == 'pinned'
Requires-Dist: langcodes==3.5.1; extra == 'pinned'
Requires-Dist: latex2mathml==3.81.1; extra == 'pinned'
Requires-Dist: logfire-api==4.37.0; extra == 'pinned'
Requires-Dist: lxml==6.1.1; extra == 'pinned'
Requires-Dist: markdown-it-py==4.2.0; extra == 'pinned'
Requires-Dist: mcp-types==2.3.0; extra == 'pinned'
Requires-Dist: mcp==2.3.0; extra == 'pinned'
Requires-Dist: mdurl==0.1.2; extra == 'pinned'
Requires-Dist: more-itertools==11.1.0; extra == 'pinned'
Requires-Dist: numpy==2.5.3; extra == 'pinned'
Requires-Dist: openai==3.31.0; extra == 'pinned'
Requires-Dist: openpyxl==3.1.5; extra == 'pinned'
Requires-Dist: opentelemetry-api==1.44.0; extra == 'pinned'
Requires-Dist: packaging==26.2; extra == 'pinned'
Requires-Dist: pandas==3.0.6; extra == 'pinned'
Requires-Dist: pillow==12.3.0; extra == 'pinned'
Requires-Dist: platformdirs==4.11.12; extra == 'pinned'
Requires-Dist: pluggy==1.6.0; extra == 'pinned'
Requires-Dist: py-key-value-aio==0.4.6; extra == 'pinned'
Requires-Dist: pycparser==3.0; ((sys_platform == 'linux' and platform_python_implementation != 'PyPy') and implementation_name != 'PyPy') and extra == 'pinned'
Requires-Dist: pycparser==3.0; (platform_python_implementation != 'PyPy' and implementation_name != 'PyPy') and extra == 'pinned'
Requires-Dist: pydantic-ai-slim==2.56.0; extra == 'pinned'
Requires-Dist: pydantic-core==2.46.4; extra == 'pinned'
Requires-Dist: pydantic-graph==2.56.0; extra == 'pinned'
Requires-Dist: pydantic-settings==2.15.0; extra == 'pinned'
Requires-Dist: pydantic==2.13.4; extra == 'pinned'
Requires-Dist: pygments==2.20.0; extra == 'pinned'
Requires-Dist: pyjwt==2.15.0; extra == 'pinned'
Requires-Dist: pylatexenc==2.11; extra == 'pinned'
Requires-Dist: pypdfium2==5.13.0; extra == 'pinned'
Requires-Dist: python-dateutil==2.9.0.post0; extra == 'pinned'
Requires-Dist: python-docx==1.2.0; extra == 'pinned'
Requires-Dist: python-dotenv==1.2.2; extra == 'pinned'
Requires-Dist: python-multipart==0.0.32; extra == 'pinned'
Requires-Dist: python-pptx==1.0.2; extra == 'pinned'
Requires-Dist: pywin32-ctypes==0.2.3; (sys_platform == 'win32') and extra == 'pinned'
Requires-Dist: pywin32==312; (sys_platform == 'win32') and extra == 'pinned'
Requires-Dist: pyyaml==6.0.3; extra == 'pinned'
Requires-Dist: referencing==0.37.0; extra == 'pinned'
Requires-Dist: regex==2026.7.19; extra == 'pinned'
Requires-Dist: requests==2.34.2; extra == 'pinned'
Requires-Dist: rich==15.0.0; extra == 'pinned'
Requires-Dist: rpds-py==2026.6.3; extra == 'pinned'
Requires-Dist: rtree==1.4.1; extra == 'pinned'
Requires-Dist: s3transfer==0.19.2; extra == 'pinned'
Requires-Dist: scipy==1.18.1; extra == 'pinned'
Requires-Dist: secretstorage==3.5.0; (sys_platform == 'linux') and extra == 'pinned'
Requires-Dist: shellingham==1.5.4; extra == 'pinned'
Requires-Dist: six==1.17.0; extra == 'pinned'
Requires-Dist: sniffio==1.3.1; extra == 'pinned'
Requires-Dist: soupsieve==2.9.2; extra == 'pinned'
Requires-Dist: sse-starlette==3.4.11; extra == 'pinned'
Requires-Dist: starlette==1.3.1; extra == 'pinned'
Requires-Dist: tabulate==0.10.0; extra == 'pinned'
Requires-Dist: tiktoken==0.13.0; extra == 'pinned'
Requires-Dist: tqdm==4.69.0; extra == 'pinned'
Requires-Dist: truststore==0.10.4; (sys_platform != 'emscripten') and extra == 'pinned'
Requires-Dist: typer==0.26.8; extra == 'pinned'
Requires-Dist: typing-extensions==4.16.0; extra == 'pinned'
Requires-Dist: typing-inspection==0.4.2; extra == 'pinned'
Requires-Dist: tzdata==2026.4; (sys_platform == 'emscripten' or sys_platform == 'win32') and extra == 'pinned'
Requires-Dist: urllib3==2.8.0; extra == 'pinned'
Requires-Dist: uvicorn==0.51.0; extra == 'pinned'
Requires-Dist: uvicorn==0.51.0; (sys_platform != 'emscripten') and extra == 'pinned'
Requires-Dist: uvloop==0.22.1; ((platform_python_implementation != 'PyPy' and sys_platform != 'cygwin') and sys_platform != 'win32') and extra == 'pinned'
Requires-Dist: watchfiles==1.2.0; extra == 'pinned'
Requires-Dist: websockets==16.1.1; extra == 'pinned'
Requires-Dist: xlsxwriter==3.2.9; extra == 'pinned'
Description-Content-Type: text/markdown

# Course Harness

Course Harness is a local-first application for creating source-grounded Courses in a
human-readable Course Workspace.

## Install and launch

Install [uv](https://docs.astral.sh/uv/). It downloads Python 3.14 if you do not have it.

```bash
uvx 'course-harness[pinned]'
```

Or install it as a command-line tool:

```bash
uv tool install 'course-harness[pinned]'
course-harness
```

The `pinned` extra installs exactly the dependency versions each release was tested with, so an
install keeps working as newer dependencies come out.

The command opens `http://127.0.0.1:8765` with the Workspace Launcher, where you create or open a
Course Workspace. Useful options:

```bash
course-harness /path/to/course-workspace  # open a Workspace directly (`.` works too)
course-harness --no-browser               # do not open the browser
course-harness --port 9000                # use another local port
```

## Set up a model

Open **Models** to add a provider. Course Harness works with OpenRouter, OpenAI, Anthropic, Amazon
Bedrock, and any OpenAI-compatible server. Under **Found on this computer**, it lists the provider
credentials it can see and lets you add each one with a single click. It looks at
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `AWS_BEARER_TOKEN_BEDROCK`, AWS session
variables (such as those set by awsume), and the profiles in `~/.aws`. None of these is used until
you add it.

For Bedrock, an AWS profile is the most robust choice: only its name is stored, and the AWS SDK
refreshes SSO and role credentials by itself. Session variables are read from the shell that
started Course Harness, so after they expire, refresh them and restart from that shell. Prompt
caching is turned on for Anthropic and OpenRouter models, and for Bedrock models that support it.

To use an OpenAI-compatible provider on another machine over plain HTTP, select **Allow HTTP to
another host**. The API key and Course content are then sent unencrypted, so use it only on a
network you trust.

## What it does

- **Sources**: add PDF, `.pptx`, `.docx`, and web pages, or discover papers through arXiv,
  Crossref, Semantic Scholar, and OpenAlex. Documents are read quickly on your device first. When
  a document looks scanned, image-heavy, or garbled, you can reprocess it with full Docling (an
  optional extra) or a hosted processor: LlamaParse, Mistral OCR, or one of your Model Presets.
  A hosted processor gets a document only after you agree to send documents to that vendor.
- **Course Agent**: plans the Course, researches sources, and writes Lectures. Only Sources added
  to the Course can be cited.
- **Connectors**: web search and page reading through remote MCP servers. Exa is set up by default
  and works without a key.
- **History**: save and restore Course Revisions, publish Releases, and export Lectures to
  PowerPoint.

Canonical Course state lives in readable files in the Course Workspace. Caches, conversations, and
credentials live in platform-native user locations outside it; credentials go to the operating
system keyring when one is available. **Settings → Diagnostics** shows the exact locations.

## Optional extras

**Full Docling for difficult PDFs.** For scanned PDFs, tables, and multi-column layouts you can
reprocess a document with full Docling, which runs layout, table, and text recognition models
locally:

```bash
uv tool install --torch-backend cpu 'course-harness[pinned,docling]'
```

`--torch-backend cpu` installs the CPU build of PyTorch. Without it, uv installs the CUDA build
on Linux, which downloads several gigabytes of GPU libraries. Course Harness asks before it
downloads the Docling models.

**LibreOffice** (`libreoffice` or `soffice` on `PATH`) gives high-fidelity slide thumbnails.

## Development

See [CONTRIBUTING.md](https://github.com/martinkozle/course-harness/blob/main/CONTRIBUTING.md)
to run Course Harness from a checkout, run the checks, and publish a release.

## License

[MIT](https://github.com/martinkozle/course-harness/blob/main/LICENSE)
