Skip to content
Open
Show file tree
Hide file tree
Changes from 73 commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
750984b
Added PDF text extraction
Apps247 May 21, 2026
5487aa3
UI Fix
Apps247 May 21, 2026
f9de9b5
Added OCR feature
Apps247 May 21, 2026
132e356
Removed broken indexing progress bar feature
Apps247 May 21, 2026
f743fc7
Added text extraction microservice
Apps247 May 21, 2026
5c58a47
Fixed AWS Textract microservice and laravel interaction
Apps247 May 21, 2026
ce4f372
Added OCR timer
Apps247 May 21, 2026
124dedc
Changed region
Apps247 May 22, 2026
0ce192d
Added docs
Apps247 May 22, 2026
bfd1fd9
Changed button colour
Apps247 May 22, 2026
ab675c9
Changed structure to match other microservices
Apps247 Jun 8, 2026
b7baf15
Added search indexing and basic UI
Apps247 Jun 9, 2026
00378d4
More robust PDF checking, and optimizations
Apps247 Jun 9, 2026
874e914
Added program search
Apps247 Jun 9, 2026
5c5c630
Clicking search result shows original page
Apps247 Jun 10, 2026
6f3126b
Updated docs
Apps247 Jun 11, 2026
2dbc2af
Switched to Spatie\PdfToImage
Apps247 Jun 11, 2026
2464bc5
Thumbnail feature, refactored, reverted to poppler
Apps247 Jun 11, 2026
2c951b9
Added comments explaining quirky code
Apps247 Jun 11, 2026
902c4e1
Added comments explaining quirky code
Apps247 Jun 11, 2026
e0af3bb
Merge branch 'aprameya/course-material-coverage-analysis' of https://…
Apps247 Jun 11, 2026
6027420
Fixed pdf to image preview issue
Apps247 Jun 12, 2026
d891b6f
Simplified DB schema
Apps247 Jun 12, 2026
edd9aa1
Refactor and improvements
Apps247 Jun 13, 2026
e760414
Added download button to program materials view, misc changes
Apps247 Jun 15, 2026
a73d7da
Refactored search controller, added thumbnails to program search
Apps247 Jun 15, 2026
a463a15
Bug fix
Apps247 Jun 15, 2026
10d3bdb
Merge remote-tracking branch 'upstream/dev' into aprameya/course-mate…
Apps247 Jun 24, 2026
9721890
Integrated CourseMaterialFile with CourseMaterial
Apps247 Jun 24, 2026
8d184e9
Course Material File UI and controller
Apps247 Jun 25, 2026
71f628a
Topic extraction service set up
Apps247 Jun 25, 2026
831bf76
Improved topic extraction
Apps247 Jun 29, 2026
7a2de2f
Added PowerPoint processing case
Apps247 Jun 29, 2026
0465a47
Refactored topic extraction into text extraction service, added type-…
Apps247 Jul 2, 2026
85e5c78
Merge remote-tracking branch 'upstream/main' into aprameya/course-mat…
Apps247 Jul 6, 2026
207d78d
Improved topic extraction for slides
Apps247 Jul 6, 2026
221b0ce
Force Ran composer install, must upgrade to Laravel 12.60 soon
Apps247 Jul 6, 2026
b463adb
Textract config and S3 bucket deploy script
Apps247 Jul 6, 2026
0743272
Text style scanning fix
Apps247 Jul 6, 2026
9b52d96
Added temporary refresh button
Apps247 Jul 8, 2026
1a01435
BERTopic improvements DRAFT
Apps247 Jul 8, 2026
e063d05
Improved BERTopic extraction DRAFT
Apps247 Jul 9, 2026
99ce22e
Improved parameters and postprocessing
Apps247 Jul 9, 2026
b02e0cd
Refactored step10 to use single template
Apps247 Jul 13, 2026
78fa49b
Added adjective-final removals and custom stopword vectorizing
Apps247 Jul 13, 2026
0190b43
Added dropdown for material type
Apps247 Jul 13, 2026
6929139
Refactored, fixed type-related bug, different splitting for small doc…
Apps247 Jul 15, 2026
670267d
Removed yake
Apps247 Jul 15, 2026
9f066d8
Imrpovements for slides and set random seed
Apps247 Jul 15, 2026
4bfef6b
Union of font and BERTopic topics, preprocessing improvements
Apps247 Jul 15, 2026
c6b4e6e
Simplified pages to text and used marginal relevance model
Apps247 Jul 15, 2026
f3e8842
Loosened threshold for slides
Apps247 Jul 16, 2026
cce8852
File topic editing UI functionality and bug fixes
Apps247 Jul 16, 2026
e922ee6
Made course topics master and material UI just add or remove
Apps247 Jul 17, 2026
e4827b0
Added topic matching method
Apps247 Jul 17, 2026
9239001
Fixed temporary search
Apps247 Jul 20, 2026
9105bec
Added review and accept system for suggested topics
Apps247 Jul 20, 2026
5eee2ab
Optimizations and cleanup
Apps247 Jul 20, 2026
740d3eb
Uppercase-lowercase Bug Fix
Apps247 Jul 20, 2026
6623dd1
Preserved case for topics
Apps247 Jul 21, 2026
c83a262
Changed port & bug fix
Apps247 Jul 22, 2026
87da677
Refactored out textract
Apps247 Jul 22, 2026
a1fb51e
Removed 1 minus for topic score
Apps247 Jul 22, 2026
cc35f17
Used model and helper to simplify ExtractedPages
Apps247 Jul 22, 2026
2e8f11e
Made only keyword and match topics refresh, text extraction and font …
Apps247 Jul 22, 2026
c952998
Removed unnecessary course_id column
Apps247 Jul 23, 2026
ca9be4a
Python tests and bug fixes
Apps247 Jul 24, 2026
a6a0285
Refactored E2E fixtures
Apps247 Jul 25, 2026
b383409
Feature test for suggested topics
Apps247 Jul 25, 2026
d37a1dc
End-to-end test
Apps247 Jul 26, 2026
2611241
Updated docs
Apps247 Jul 27, 2026
bdda3e9
File upload potential fix
Apps247 Jul 27, 2026
d7fcfbf
pytest OCR test
Apps247 Jul 27, 2026
664e7fb
Requested changes
Apps247 Jul 30, 2026
b060ad0
Added enum for topic source and docstring
Apps247 Jul 30, 2026
43ac4c6
Further requested changes for server and tests
Apps247 Jul 31, 2026
0c330b1
PHP-side requested changes
Apps247 Jul 31, 2026
5a4b834
Set allow_credentials=False
Apps247 Jul 31, 2026
877bb3d
Multi-part file sending
Apps247 Jul 31, 2026
fce7f59
Made search result highlighting more robust
Apps247 Jul 31, 2026
64d951a
Bug fix
Apps247 Jul 31, 2026
009698a
Added retry button if indexing failed
Apps247 Jul 31, 2026
1096066
Updated Schema doc
Apps247 Jul 31, 2026
1892cb6
Bug fix
Apps247 Jul 31, 2026
7740e75
Added S3 deletion on job completion or failure
Apps247 Jul 31, 2026
780bb6d
Fixed material type requirement and added new test
Apps247 Jul 31, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ laravel/database/seeders/UserSeeder.php
laravel/resources/views/emails/
laravel/.phpunit.cache
.phpunit.result.cache
laravel/.phpunit.cache

laravel/tests/Browser/Screenshots

Expand All @@ -37,6 +38,7 @@ experiments/llm-evaluation/venv
python/services/logs/
python/services/*/env/
python/services/lo_mapping_service/app/services/batch_inputs/**
python/services/lo_mapping_service/deploy/*.zip
**.env
python/services/lo_mapping_service/deploy/*.zip
python/services/lo_mapping_service/env
Expand Down
156 changes: 156 additions & 0 deletions docs/FastAPITextExtractionService.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# FastAPI Text Extraction Service

## Overview

The Text Extraction Service is a FastAPI application that extracts per-page text, and file-wide topics from PDF course materials.

Text extraction is performed using a PDF parser, or OCR if required (using Tesseract or Textract). Topic extraction uses a combination of font properties and BERTopic scores to identify suggested topics.

## Primary Responsibility

This service is responsible for:

- accepting text-topic extraction and topic refresh requests
- extracting text content from PDF files, page by page, and using OCR if requested
- extracting potential topics from document text by
- using BERTopic to extract keywords/phrases from the text
- using Font properties (or estimated font properties with OCR) and the material type to identify potential topics
- finding existing course topics in course text

## API

### `GET /health`

Returns a simple response:

```json
{
"status": "ok"
}
```

### `POST /extract`

The main endpoint. Accepts an [`ExtractRequest`](../python/services/text_extraction_service/app/schemas.py) with:

- `file` (string, required): base64-encoded PDF content
- `ocr_enabled` (boolean, default `false`): whether to apply OCR on low-text pages
- `extraction_engine` (string, default `"tesseract"`): `"tesseract"` or `"textract"`
- `ocr_threshold` (integer, default `0`): page text length below which OCR is triggered
Comment thread
Apps247 marked this conversation as resolved.
Outdated
- `material_type` (string, optional): `"slides"`, `"article"`, or `null` for default handling
- `existing_topics` (list of strings, default `[]`): course topics to match against extracted text

Note (TODO): Having both `ocr_enabled` and `extraction_engine` is slightly redundant - we could have a third `extraction_engine` option be `"text-only"` or similar to cover both. However, this set up allows us to easily remove textract if we need to, as that was discussed to help simplify the set up but later retained as the service was modularized further. A potential improvement is having an `int` field with `0` correspond to text-only, and `1, 2, ...` correspond to OCR engines.

Example request:

```json
{
"file": "JVBERi0xLjQK...",
"ocr_enabled": true,
"extraction_engine": "tesseract",
"ocr_threshold": 50,
"material_type": "slides",
"existing_topics": ["Climate Change", "Forest Ecology"]
}
```

Returns an [`ExtractResponse`](../python/services/text_extraction_service/app/schemas.py) with:

- `pages`: list of `{ page_number, content }` for each page with extracted text
- `page_count`: total number of pages in the PDF
- `topics`: list of `{ topic, score, source }` extracted from the document

### `POST /refresh-topics`

Re-extracts topics from existing page content without re-running text extraction. Accepts a [`RefreshTopicsRequest`](../python/services/text_extraction_service/app/schemas.py) with:

- `pages` (list, required): list of `{ page_number, content }` objects from a previous extraction
- `material_type` (string, optional): `"slides"`, `"article"`, or `null`
- `existing_topics` (list of strings, default `[]`): course topics to match against

Example request:

```json
{
"pages": [
{ "page_number": 1, "content": "Introduction to Forest Ecology" },
{ "page_number": 2, "content": "Climate Change Impacts" }
],
"material_type": "article",
"existing_topics": ["Climate Change", "Forest Ecology"]
}
```

Returns the same `ExtractResponse` format as `/extract`.

## Text Extraction

The extraction pipeline in [`document_extractor.py`](../python/services/text_extraction_service/app/services/text_readers/document_extractor.py) works as follows:

1. PDF is opened with PyMuPDF
2. For each page:
- If OCR is enabled and the page has little text (below `ocr_threshold`), the page is processed with Tesseract or Textract (see **OCR Engines** below).
- Otherwise, text is extracted directly from the text layer with font size and font weight.d
3. Each page returns a list of lines with `{ text, size, bold }` metadata. The `size` and `bold` may be `None` depending on the extraction engine.

### OCR Engines

- **Tesseract**: Renders page to image at 300 DPI, groups words into lines, estimates font size from word-box height. Estimating whether the font is bold or not is possible but quite complex, so isn't implemented for now.
- **AWS Textract**: Single-page PDF extraction uses a synchronous call. Multi-page PDFs are uploaded to S3, processed asynchronously, and the service polls for completion. Textract returns line text only, no font metadata. However, Textract is much faster than Tesseract for large PDFs.

## Topic Extraction Pipeline

Topic extraction is handled by [`type_specific_handlers.py`](../python/services/text_extraction_service/app/services/topic_extraction/type_specific_handlers.py) with material-type-specific handlers. Currently, there is a specific handler for Slides, and one for Articles, as well as a default handler.

### BERTopic Extraction

The BERTopic extractor in [`bertopic_extractor.py`](../python/services/text_extraction_service/app/services/topic_extraction/bertopic_extractor.py):

1. Lemmatizes text with spaCy to collapse plurals
2. Splits text into overlapping windows
3. Fits BERTopic with the `all-mpnet-base-v2` embedding model.
4. Extracts top keywords from each cluster
5. Returns up to `TOPICS_COUNT` (currently 100) topics, then sorts by relevance. This limit should ideally never be reached, and is only there to prevent the database exploding with topics. We can increase the limit any time.
Comment thread
Apps247 marked this conversation as resolved.
Outdated

## Runtime Dependencies

Outside of what is installed via `pip`, you will need two dependencies:

**Tesseract**: Local OCR engine. Install `tesseract-ocr` via package manager or from the [official Tesseract page](https://tesseract-ocr.github.io/tessdoc/Installation.html) and ensure `tesseract` is on your `PATH`. English language data (`eng`) must be included.

Verify Tesseract is reachable and has English data:

```
tesseract --version
tesseract --list-langs # eng must appear in output
```

**spaCy English model**: The English NLP model used by the BERTopic extraction logic for Text lematization. After running `pip install -r requirements.txt`, run
```
python -m spacy download en_core_web_sm
```
Note that `_sm` stands for 'small', and is the lightest model. Heavier models like `en_core_web_lg` may yield better results, but also use more resources and space.

## Key Environment Variables

The environment variables are only used for Textract OCR.
If using Tesseract, none are needed.

- `AWS_ACCESS_KEY_ID`: AWS access key for Textract (optional)
- `AWS_SECRET_ACCESS_KEY`: AWS secret key for Textract (optional)
- `AWS_REGION`: AWS region for Textract (default: `ca-central-1`)
- `AWS_S3_BUCKET`: S3 bucket for async Textract jobs (default: `text-extraction-temp`)

## Error Handling

Both `/extract` and `/refresh-topics` return HTTP 500 with the error message when processing fails.

## Test Coverage
The `tests/` folder for this service contains tests for the various file handlers and postprocessor:
- **Default Material Handling** with topic matching
- **Slides Handling** with font-based extraction
- **Article Handling** with font-based extraction and soft assertion for BERTopic extraction
- **Postprocessing** with various patterns and edge cases

AWS Textract OCR isn't covered in the tests, as it is not available in LocalStack and costs credits to use.
3 changes: 2 additions & 1 deletion docs/HighLevelArchitecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

## Overview

This application is split into a primary Laravel web application and two Python services:
This application is split into a primary Laravel web application and a set of Python services:

- `laravel/`: the main user interface of the Curriculum Mapping Tool with courses, programs, syllabi, persistence, permissions, and reporting
- `python/services/syllabi_service/`: a FastAPI service that parses uploaded syllabus files and extracts structured course data
- `python/services/lo_mapping_service/`: a FastAPI service that prepares and manages learning-outcome mapping via LLM (Qwen/Qwen3-8B) and post-processes the results
- `python/services/text_extraction_service/`: a FastAPI service that extracts text from PDF course materials (with Tesseract OCR if it's a scanned PDF) and also extracts likely topics based on keyword extraction and font properties

![Architecture Diagram](./images/ArchitectureDiagram.png)

Expand Down
34 changes: 31 additions & 3 deletions docs/Setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ npm install
cp .env.example .env
```

The committed `.env.example` is the template every developer starts from. Its relevant database section is:
Use `.env.example` as a template for `.env`, and modify values as required.

```
DB_CONNECTION=pgsql
Expand Down Expand Up @@ -76,13 +76,13 @@ If you see `psql: command not found`, PostgreSQL is not installed; install it fi

#### Create the `root` role

`initdb` creates the `postgres` superuser but not `root`. Since `.env.example` ships with `DB_USERNAME=root`, create that role once:
Create the root role:

```
psql -U postgres -h 127.0.0.1 -c "CREATE ROLE root LOGIN PASSWORD '';"
```

The empty password matches `DB_PASSWORD=` in the template. // TODO Check this
The above command has a blank password. If setting a password, also set it in `.env`, under `DB_PASSWORD=`

#### Create the application database

Expand All @@ -97,6 +97,28 @@ psql -U postgres -h 127.0.0.1 -c "CREATE DATABASE laravel OWNER root;"
php artisan storage:link
```

### Optional: PHP upload limits (required for Course Material File uploads)

The Course Material File extraction feature lets users upload PDF course materials. PHP's default limits are small, so set these larger values in your active `php.ini`:

```ini
upload_max_filesize = 50M
post_max_size = 60M
memory_limit = 256M
```

Your active `php.ini` is the file listed as **"Loaded Configuration File"** when you run

```
php --ini
```

### Optional: PDF Thumbnails for Materials Search

Rendering thumbnails requires `pdftoppm` from `poppler-utils`. Install via package manager or from [Poppler releases page](https://poppler.freedesktop.org/) and ensure `pdftoppm` is on your `PATH`.

Verify with `pdftoppm -v`.

### Run migrations and seeders

```
Expand Down Expand Up @@ -133,6 +155,11 @@ Start the queue worker in one terminal:
php artisan queue:work
```

Note: If you need OCR, add this flag
```
php artisan queue:work --memory=2048
```

Run the application in another:

```
Expand Down Expand Up @@ -227,6 +254,7 @@ python -m venv env
```
pip install -r requirements.txt
```
Also check the service's documentation under `docs/` for additional specific setup steps.

### Run the Service

Expand Down
6 changes: 6 additions & 0 deletions laravel/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
# QUEUE_CONNECTION=database # Use database if you're using OCR
SESSION_DRIVER=file
SESSION_LIFETIME=120

Expand Down Expand Up @@ -48,3 +49,8 @@ VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

PYTHON_API_URL=http://

# Required on Windows only
# Required for Tesseract OCR and search PDF previews
# Should look like path\to\pdftoppm.exe
PDFTOPPM_PATH=
16 changes: 16 additions & 0 deletions laravel/app/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\PostTooLargeException;
use Throwable;

class Handler extends ExceptionHandler
Expand All @@ -26,5 +27,20 @@ public function register(): void
$this->reportable(function (Throwable $e) {
//
});

$this->renderable(function (PostTooLargeException $e, $request) {
$limit = ini_get('post_max_size');
$referer = $request->headers->get('referer');

if ($referer) {
$separator = str_contains($referer, '?') ? '&' : '?';
return redirect($referer . $separator . 'upload_error=too_large&limit=' . urlencode($limit));
}

return response(
"Upload too large. The current server limit is {$limit}B. Please go back and try a smaller file.",
413
);
});
}
}
Loading