Quellcode durchsuchen

feat: implement AI-assisted data entry system with FastAPI backend and Angular UI for automated receipt extraction and claim management

Dr-Swopt vor 2 Tagen
Ursprung
Commit
406b2724a7

+ 2 - 0
.gitignore

@@ -5,3 +5,5 @@ venv/
 .pytest_cache/
 .vscode/
 .idea/
+
+data.db

+ 237 - 37
README.md

@@ -1,82 +1,282 @@
-# AI-Assisted Smart Data Entry Backend
+# AI-Assisted Smart Data Entry
 
-This project provides a FastAPI-based backend for extracting structured data from medical receipts using OpenAI's GPT-4o model with structured outputs.
+A full-stack prototype for AI-assisted medical claim submission. The system uses OpenAI's GPT-4o-mini Vision model to automatically extract data from uploaded medical receipt images and populate a structured claim form, reducing manual data entry effort for HR and finance teams.
+
+---
 
 ## Features
 
-- **FastAPI**: Modern, fast web framework for building APIs.
-- **Structured Extraction**: Uses OpenAI's `beta.chat.completions.parse` to ensure extracted data adheres to a Pydantic schema.
-- **CORS Enabled**: Configured to allow access from frontend applications (e.g., Angular).
-- **Health Check**: Simple endpoint to verify server status.
+- **Dual AI Extraction Tracks**
+  - **V1 – Smart Auto-Fill**: Sends the receipt image to a fixed structured schema (Pydantic-enforced). Returns fields like provider name, date, amount, items, confidence score, and flags receipts needing manual review.
+  - **V2 – Template-Fill**: Accepts a configurable field template and fills only the fields explicitly present in the image. Stricter — returns `null` for any field that cannot be confirmed.
+- **Visual AI Indicators**: Form fields are colour-coded to distinguish V1-filled (blue) vs V2-filled (purple) vs manually entered values.
+- **SQLite Persistence**: Claims and users are stored in a local `data.db` SQLite database via SQLAlchemy ORM.
+- **Allowance Tracking**: Each submitted claim deducts from the user's medical allowance. Deleting a claim refunds the deducted amount.
+- **Confidence Scoring & Manual Review Flags**: V1 extraction returns a confidence score and a `needs_manual_review` flag for ambiguous receipts.
+- **Image Compression**: Uploaded images are resized and compressed (max 2000×2000, JPEG quality 85) before being sent to the API.
+- **Angular Frontend**: Single-page Angular app with a claim submission form and a claims dashboard.
+
+---
+
+## Project Structure
+
+```
+AI-Assisted Smart Data Entry/
+├── backend/
+│   ├── __init__.py
+│   ├── main.py            # FastAPI app, routes, startup seeding
+│   ├── database.py        # SQLAlchemy engine & session setup (SQLite)
+│   ├── models.py          # SQLAlchemy ORM models (User, Claim)
+│   ├── schemas.py         # Pydantic schemas for request/response validation
+│   ├── crud.py            # Database CRUD operations and business logic
+│   └── services/
+│       └── openai_service.py  # OpenAI Vision API calls (V1 & V2)
+├── ai-data-entry-ui/          # Angular 17+ frontend
+│   └── src/
+│       ├── index.html
+│       ├── main.ts
+│       ├── styles.css
+│       └── app/
+│           ├── app.ts
+│           ├── app.routes.ts
+│           ├── app.config.ts
+│           ├── components/
+│           │   ├── claim-form/         # Claim submission form with AI fill buttons
+│           │   └── claims-dashboard/   # Dashboard listing all submitted claims
+│           └── services/
+│               ├── extraction.ts           # TypeScript interfaces (models)
+│               ├── extraction.service.ts   # HTTP service for all API calls
+│               └── session.service.ts      # In-memory session / current user state
+├── sample_medical_receipts/   # Sample images for testing
+├── data.db                    # SQLite database (auto-created on first run)
+├── requirements.txt
+├── .env                       # API key (not tracked by git)
+└── .gitignore
+```
+
+---
 
 ## Setup
 
+### Prerequisites
+
+- Python 3.10+
+- Node.js 18+ and npm
+- OpenAI API key
+
 ### Backend (FastAPI)
 
-1. **Initialize Virtual Environment**:
+1. **Create and activate a virtual environment**:
    ```bash
-   # Create environment
    python -m venv venv
 
-   # Activate environment (Windows)
+   # Windows
    .\venv\Scripts\activate
 
-   # Activate environment (Mac/Linux)
-   # source venv/bin/activate
+   # Mac/Linux
+   source venv/bin/activate
    ```
 
-2. **Install Dependencies**:
+2. **Install dependencies**:
    ```bash
    pip install -r requirements.txt
    ```
 
-2. **Configure Environment**:
-   Create a `.env` file in the root directory and add your OpenAI API key:
+3. **Configure environment variables**:
+   Create a `.env` file in the project root:
    ```env
-   OPENAI_API_KEY=your_api_key_here
+   OPENAI_API_KEY=your_openai_api_key_here
    ```
 
-3. **Run the Server**:
+4. **Run the server**:
    ```bash
    uvicorn backend.main:app --reload
    ```
+   The API will be available at `http://localhost:8000`.  
+   On first startup, the database is created and a **Demo User** (ID: `demo-user-123`, allowance: MYR 5,000) is automatically seeded.
 
 ### Frontend (Angular)
 
-1. **Install Dependencies**:
+1. **Install dependencies**:
    ```bash
    cd ai-data-entry-ui
    npm install --legacy-peer-deps
    ```
 
-2. **Run the UI**:
+2. **Run the development server**:
    ```bash
-   npm start
+   ng serve
    ```
+   The UI will be available at `http://localhost:4200`.
+
+---
 
 ## API Endpoints
 
 ### `GET /health`
-Returns the status of the server.
+Returns server health status.
 
-### `POST /api/v1/extract`
-Extracts data from a medical receipt image.
+---
 
-**Request Body**:
-- `file`: Multipart image file.
-- `user_name` (optional): Name of the employee.
-- `department` (optional): Department of the employee.
+### `GET /api/v1/users`
+Returns a list of all users.
 
-**Response**:
-Returns a structured JSON matching the `ExtractionResponse` schema.
+**Response**: `UserAccount[]`
 
-## Project Structure
+---
+
+### `POST /api/v1/users`
+Creates a new user.
+
+**Request Body** (JSON):
+```json
+{
+  "id": "string",
+  "name": "string",
+  "department": "string",
+  "medical_allowance": 5000.0
+}
+```
+
+---
+
+### `POST /api/v1/extract` — V1 AI Extraction
+Extracts structured data from a medical receipt image using a fixed schema.
+
+**Request**: `multipart/form-data`
+| Field | Type | Description |
+|---|---|---|
+| `file` | Image file | The medical receipt image |
+| `user_name` | string (optional) | Employee name (default: `Demo User`) |
+| `department` | string (optional) | Employee department (default: `Operations`) |
+
+**Response** (`ExtractionResponse`):
+```json
+{
+  "provider_name": "Klinik Sejahtera",
+  "visit_date": "2024-03-15",
+  "total_amount": 85.00,
+  "currency": "MYR",
+  "items": ["Consultation", "Medicine"],
+  "confidence_score": 0.92,
+  "needs_manual_review": false,
+  "ai_reasoning": "Clinic name and stamp clearly visible on letterhead.",
+  "receipt_ref_no": "INV-00123",
+  "clinic_reg_no": "MOH/12345",
+  "claim_category": "General",
+  "diagnosis_brief": "Fever consultation and prescribed medication."
+}
+```
+
+---
+
+### `POST /api/v2/fill-template` — V2 Template-Driven Fill
+Fills a user-defined field template from the receipt image. Only returns values explicitly found in the image.
+
+**Request**: `multipart/form-data`
+| Field | Type | Description |
+|---|---|---|
+| `file` | Image file | The medical receipt image |
+| `template_fields` | JSON string | Map of `fieldKey → description` |
+| `user_name` | string (optional) | Employee name |
+| `department` | string (optional) | Employee department |
+
+**Example `template_fields`**:
+```json
+{
+  "provider_name": "Name of clinic/hospital",
+  "visit_date": "Date of treatment (YYYY-MM-DD)",
+  "amount_spent": "Total amount paid",
+  "receipt_ref_no": "Invoice or reference number"
+}
+```
+
+**Response** (`V2TemplateResponse`):
+```json
+{
+  "filled_data": [
+    { "key": "provider_name", "value": "Klinik Sejahtera" },
+    { "key": "amount_spent", "value": 85.00 }
+  ],
+  "unfilled_fields": ["receipt_ref_no"]
+}
+```
+
+---
+
+### `POST /api/v1/claims`
+Submits a medical claim. Deducts the claimable amount from the user's allowance (capped at remaining balance).
+
+**Request Header**: `user-id: <user_id>`
+
+**Request Body** (`ClaimSubmission`):
+```json
+{
+  "provider_name": "string",
+  "visit_date": "YYYY-MM-DD",
+  "amount_spent": 85.00,
+  "currency": "MYR",
+  "treatment_type": "Outpatient",
+  "cost_center": "CC-001",
+  "declaration_signed": true,
+  "extraction_data": { }
+}
+```
+
+---
+
+### `GET /api/v1/claims`
+Returns all submitted claims (all users).
+
+---
+
+### `DELETE /api/v1/claims/{claim_id}`
+Deletes a claim and refunds the deducted amount back to the user's allowance.
+
+---
+
+## Data Models
+
+### Backend (SQLAlchemy)
+
+| Model | Fields |
+|---|---|
+| `User` | `id`, `name`, `department`, `medical_allowance` |
+| `Claim` | `id`, `timestamp`, `user_id` (FK), `amount_spent`, `amount_claimed`, `provider_name`, `visit_date`, `treatment_type`, `cost_center`, `declaration_signed`, `extraction_data` (JSON text) |
+
+### Frontend (TypeScript Interfaces)
+
+Defined in `extraction.ts`: `UserAccount`, `ExtractionResponse`, `ClaimSubmission`, `ClaimRecord`, `V2Field`, `V2TemplateResponse`.
+
+---
+
+## Frontend Components
+
+### `ClaimFormComponent`
+The main data entry view. Features:
+- Drag-and-drop or click-to-upload receipt image with live preview.
+- **"Auto-Fill with AI" (V1)** button — calls `/api/v1/extract` and populates the form.
+- **"Template Fill (V2)"** button — calls `/api/v2/fill-template` and populates fields found in the image.
+- Colour-coded field borders: **blue** = filled by V1, **purple** = filled by V2.
+- Real-time claimable amount calculation (capped by remaining allowance).
+- Raw JSON response toggle for debugging.
+- Declaration checkbox required before submission.
+
+### `ClaimsDashboardComponent`
+Lists all submitted claims with delete functionality and displays the current user's remaining medical allowance.
+
+---
+
+## Dependencies
+
+**Backend** (`requirements.txt`):
+- `fastapi`
+- `uvicorn`
+- `openai>=1.50.0`
+- `python-dotenv`
+- `python-multipart`
+- `pydantic>=2.0`
+- `Pillow`
+- `sqlalchemy`
 
-- `backend/`
-  - `main.py`: Entry point and API endpoints.
-  - `schemas.py`: Pydantic models for data validation and structured output.
-  - `services/`:
-    - `openai_service.py`: Logic for interacting with OpenAI API.
-- `ai-data-entry-ui/`: Angular frontend.
-- `requirements.txt`: Python dependencies.
-- `.env`: Environment variables (not tracked by git).
+**Frontend**: Angular 17+, `lucide-angular` (icons), Angular Reactive Forms, Angular Router.

+ 33 - 0
ai-data-entry-ui/src/app/components/claim-form/claim-form.component.css

@@ -130,6 +130,11 @@
     vertical-align: middle;
 }
 
+.v2-badge {
+    background: rgba(59, 130, 246, 0.1);
+    color: #1e40af;
+}
+
 .submit-btn {
     width: 100%;
     padding: 1rem;
@@ -416,3 +421,31 @@
 .w-full {
     width: 100%;
 }
+
+.mb-2 {
+    margin-bottom: 0.75rem;
+}
+
+.action-btn-v2 {
+    background: linear-gradient(135deg, #3b82f6 0%, #2dd4bf 100%);
+    color: #fff;
+    border: none;
+    padding: 1rem 1.5rem;
+    border-radius: 8px;
+    font-weight: 700;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    transition: transform 0.2s, opacity 0.2s;
+}
+
+.action-btn-v2:hover:not(:disabled) {
+    transform: translateY(-2px);
+}
+
+.action-btn-v2:disabled {
+    opacity: 0.7;
+    cursor: not-allowed;
+}

+ 49 - 13
ai-data-entry-ui/src/app/components/claim-form/claim-form.component.html

@@ -43,9 +43,13 @@
 
       <!-- Optional AI Assistant Trigger -->
       <div class="ai-assistant-panel" *ngIf="selectedFile">
-         <button class="action-btn w-full" (click)="autoFillWithAI()" [disabled]="isLoading">
+         <button class="action-btn w-full mb-2" (click)="autoFillWithAI()" [disabled]="isV1Loading || isV2Loading">
            <lucide-icon [name]="Wand2" class="icon-small"></lucide-icon> 
-           {{ isLoading ? 'Extracting details...' : 'Auto-fill form with AI' }}
+           {{ isV1Loading ? 'Extracting details...' : 'Auto-fill (V1 - Standard)' }}
+         </button>
+         <button class="action-btn-v2 w-full" (click)="runTemplateFillV2()" [disabled]="isV1Loading || isV2Loading">
+           <lucide-icon [name]="Wand2" class="icon-small"></lucide-icon> 
+           {{ isV2Loading ? 'Mapping fields...' : 'Template-Fill (V2 - Direct)' }}
          </button>
          <p class="helper-text" *ngIf="errorMessage" style="color: #ef4444; margin-top: 0.5rem">{{ errorMessage }}</p>
       </div>
@@ -77,7 +81,11 @@
         
         <div class="financial-row">
           <div class="form-field">
-            <label>Amount Spent (MYR) <span class="auto-filled-badge" *ngIf="isAutoFilled('amount_spent')">AI</span></label>
+            <label>
+              Amount Spent (MYR) 
+              <span class="auto-filled-badge" *ngIf="isAutoFilled('amount_spent')">V1</span>
+              <span class="auto-filled-badge v2-badge" *ngIf="isV2Filled('amount_spent')">V2</span>
+            </label>
             <input type="number" formControlName="amount_spent" step="0.01" placeholder="E.g. 150.00">
           </div>
           <div class="form-field">
@@ -89,17 +97,29 @@
         </div>
 
         <div class="form-field">
-          <label>Provider Name <span class="auto-filled-badge" *ngIf="isAutoFilled('provider_name')">AI</span></label>
+          <label>
+            Provider Name 
+            <span class="auto-filled-badge" *ngIf="isAutoFilled('provider_name')">V1</span>
+            <span class="auto-filled-badge v2-badge" *ngIf="isV2Filled('provider_name')">V2</span>
+          </label>
           <input type="text" formControlName="provider_name" placeholder="E.g. Klinik Kesihatan">
         </div>
         
         <div class="financial-row">
           <div class="form-field">
-            <label>Visit Date <span class="auto-filled-badge" *ngIf="isAutoFilled('visit_date')">AI</span></label>
+            <label>
+              Visit Date 
+              <span class="auto-filled-badge" *ngIf="isAutoFilled('visit_date')">V1</span>
+              <span class="auto-filled-badge v2-badge" *ngIf="isV2Filled('visit_date')">V2</span>
+            </label>
             <input type="date" formControlName="visit_date">
           </div>
           <div class="form-field">
-             <label>Claim Category <span class="auto-filled-badge" *ngIf="isAutoFilled('claim_category')">AI</span></label>
+             <label>
+               Claim Category 
+               <span class="auto-filled-badge" *ngIf="isAutoFilled('claim_category')">V1</span>
+               <span class="auto-filled-badge v2-badge" *ngIf="isV2Filled('claim_category')">V2</span>
+             </label>
              <select formControlName="claim_category">
                <option value="" disabled selected>Select Category</option>
                <option *ngFor="let cat of categories" [value]="cat">{{ cat }}</option>
@@ -117,17 +137,29 @@
         
         <div class="financial-row">
           <div class="form-field">
-            <label>Receipt Reference No <span class="auto-filled-badge" *ngIf="isAutoFilled('receipt_ref_no')">AI</span></label>
+            <label>
+              Receipt Reference No 
+              <span class="auto-filled-badge" *ngIf="isAutoFilled('receipt_ref_no')">V1</span>
+              <span class="auto-filled-badge v2-badge" *ngIf="isV2Filled('receipt_ref_no')">V2</span>
+            </label>
             <input type="text" formControlName="receipt_ref_no" placeholder="Inv-12345">
           </div>
           <div class="form-field">
-            <label>Clinic/Hospital Reg No <span class="auto-filled-badge" *ngIf="isAutoFilled('clinic_reg_no')">AI</span></label>
+            <label>
+              Clinic/Hospital Reg No 
+              <span class="auto-filled-badge" *ngIf="isAutoFilled('clinic_reg_no')">V1</span>
+              <span class="auto-filled-badge v2-badge" *ngIf="isV2Filled('clinic_reg_no')">V2</span>
+            </label>
             <input type="text" formControlName="clinic_reg_no" placeholder="E.g. 123456-X">
           </div>
         </div>
 
         <div class="form-field">
-           <label>Diagnosis / Items Brief <span class="auto-filled-badge" *ngIf="isAutoFilled('diagnosis_brief')">AI</span></label>
+           <label>
+             Diagnosis / Items Brief 
+             <span class="auto-filled-badge" *ngIf="isAutoFilled('diagnosis_brief')">V1</span>
+             <span class="auto-filled-badge v2-badge" *ngIf="isV2Filled('diagnosis_brief')">V2</span>
+           </label>
            <input type="text" formControlName="diagnosis_brief" placeholder="E.g. Consultation and Flu medication">
         </div>
       </div>
@@ -163,7 +195,7 @@
       </div>
 
       <div class="form-actions">
-        <button type="submit" [disabled]="claimForm.invalid || isLoading || isSubmitting" class="submit-btn" [class.loading]="isLoading || isSubmitting">
+        <button type="submit" [disabled]="claimForm.invalid || isV1Loading || isV2Loading || isSubmitting" class="submit-btn" [class.loading]="isV1Loading || isV2Loading || isSubmitting">
            {{ isSubmitting ? 'Finalizing Claim...' : 'Submit Official Claim' }}
         </button>
         <p class="error-text" *ngIf="claimForm.invalid && claimForm.touched">Please complete all required fields and sign the declaration.</p>
@@ -171,16 +203,20 @@
     </form>
 
     <!-- AI TRANSPARENCY -->
-    <div class="transparency-section" *ngIf="extractionResponse">
+    <div class="transparency-section" *ngIf="extractionResponse || v2Response">
        <button type="button" class="debug-toggle" (click)="showRawJson = !showRawJson">
          <lucide-icon [name]="showRawJson ? EyeOff : Eye" class="icon-small"></lucide-icon>
          {{ showRawJson ? 'Hide' : 'View' }} AI Interpretation
        </button>
        
        <div *ngIf="showRawJson" class="raw-json-view">
-         <div class="ai-reasoning-box" *ngIf="extractionResponse.ai_reasoning">
+         <div class="ai-reasoning-box" *ngIf="extractionResponse?.ai_reasoning">
            <h4>AI Reasoning</h4>
-           <p>{{ extractionResponse.ai_reasoning }}</p>
+           <p>{{ extractionResponse?.ai_reasoning }}</p>
+         </div>
+         <div class="ai-reasoning-box" *ngIf="v2Response">
+           <h4>V2 Template Interpretation</h4>
+           <p>The AI acted as a data entry clerk, mapping your specific form fields directly to the identified evidence in the receipt.</p>
          </div>
          <h4>Raw Data Extraction</h4>
          <pre><code>{{ rawJson }}</code></pre>

+ 55 - 6
ai-data-entry-ui/src/app/components/claim-form/claim-form.component.ts

@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
 import { Router, RouterModule } from '@angular/router';
 import { ExtractionService } from '../../services/extraction.service';
 import { SessionService, User } from '../../services/session.service';
-import { ExtractionResponse, ClaimSubmission } from '../../services/extraction';
+import { ExtractionResponse, ClaimSubmission, V2TemplateResponse } from '../../services/extraction';
 import { LucideAngularModule, ShieldCheck, AlertCircle, CheckCircle, ArrowLeft, Eye, EyeOff, Wand2, ClipboardList } from 'lucide-angular';
 import { Subscription } from 'rxjs';
 
@@ -21,7 +21,9 @@ export class ClaimFormComponent implements OnInit, OnDestroy {
   previewUrl: string | null = null;
   selectedFile: File | null = null;
   extractionResponse: ExtractionResponse | null = null;
-  isLoading = false;
+  v2Response: V2TemplateResponse | null = null;
+  isV1Loading = false;
+  isV2Loading = false;
   isSubmitting = false;
   isDragging = false;
   showRawJson = false;
@@ -40,6 +42,7 @@ export class ClaimFormComponent implements OnInit, OnDestroy {
 
   // Track which fields were auto-filled by AI
   autoFilledFields: Set<string> = new Set();
+  v2FilledFields: Set<string> = new Set();
 
   categories = ['General', 'Dental', 'Optical', 'Specialist'];
   treatmentTypes = ['Outpatient', 'Inpatient', 'Dental Care', 'Optical Care', 'Health Screening'];
@@ -138,9 +141,10 @@ export class ClaimFormComponent implements OnInit, OnDestroy {
   autoFillWithAI(): void {
     if (!this.currentUser || !this.selectedFile) return;
 
-    this.isLoading = true;
+    this.isV1Loading = true;
     this.errorMessage = null;
     this.autoFilledFields.clear();
+    this.v2Response = null; 
     
     this.extractionService.extractData(this.selectedFile, this.currentUser.name, this.currentUser.department).subscribe({
       next: (response) => {
@@ -174,12 +178,53 @@ export class ClaimFormComponent implements OnInit, OnDestroy {
         }
 
         this.claimForm.patchValue(patchData);
-        this.isLoading = false;
+        this.isV1Loading = false;
       },
       error: (err: Error) => {
         console.error('Extraction failed', err);
         this.errorMessage = err.message;
-        this.isLoading = false;
+        this.isV1Loading = false;
+      }
+    });
+  }
+
+  runTemplateFillV2(): void {
+    if (!this.currentUser || !this.selectedFile) return;
+
+    this.isV2Loading = true;
+    this.errorMessage = null;
+    this.autoFilledFields.clear();
+    this.v2FilledFields.clear();
+    this.extractionResponse = null;
+
+    const template = {
+      "provider_name": "Name of clinic/hospital",
+      "visit_date": "Date of treatment (YYYY-MM-DD)",
+      "amount_spent": "Total amount paid",
+      "receipt_ref_no": "Invoice or reference number",
+      "clinic_reg_no": "SSM or Registration Number",
+      "diagnosis_brief": "Short summary of treatment"
+    };
+
+    this.extractionService.fillTemplateV2(this.selectedFile, template, this.currentUser.name, this.currentUser.department).subscribe({
+      next: (response) => {
+        const patchData: any = {};
+        
+        response.filled_data.forEach(field => {
+          if (field.value !== null) {
+            patchData[field.key] = field.value;
+            this.v2FilledFields.add(field.key);
+          }
+        });
+
+        this.v2Response = response;
+        this.claimForm.patchValue(patchData);
+        this.isV2Loading = false;
+      },
+      error: (err: Error) => {
+        console.error('Template fill failed', err);
+        this.errorMessage = err.message;
+        this.isV2Loading = false;
       }
     });
   }
@@ -218,7 +263,11 @@ export class ClaimFormComponent implements OnInit, OnDestroy {
     return this.autoFilledFields.has(fieldName);
   }
 
+  isV2Filled(fieldName: string): boolean {
+    return this.v2FilledFields.has(fieldName);
+  }
+
   get rawJson(): string {
-    return JSON.stringify(this.extractionResponse, null, 2);
+    return JSON.stringify(this.extractionResponse || this.v2Response, null, 2);
   }
 }

+ 15 - 1
ai-data-entry-ui/src/app/services/extraction.service.ts

@@ -1,13 +1,14 @@
 import { Injectable } from '@angular/core';
 import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
 import { Observable, catchError, throwError } from 'rxjs';
-import { ExtractionResponse, ClaimRecord, UserAccount, ClaimSubmission } from './extraction';
+import { ExtractionResponse, ClaimRecord, UserAccount, ClaimSubmission, V2TemplateResponse } from './extraction';
 
 @Injectable({
   providedIn: 'root'
 })
 export class ExtractionService {
   private apiUrl = 'http://localhost:8000/api/v1';
+  private v2ApiUrl = 'http://localhost:8000/api/v2';
 
   constructor(private http: HttpClient) { }
 
@@ -37,6 +38,19 @@ export class ExtractionService {
       );
   }
 
+  fillTemplateV2(file: File, template: any, userName: string = 'Demo User', department: string = 'R&D'): Observable<V2TemplateResponse> {
+    const formData = new FormData();
+    formData.append('file', file);
+    formData.append('template_fields', JSON.stringify(template));
+    formData.append('user_name', userName);
+    formData.append('department', department);
+
+    return this.http.post<V2TemplateResponse>(`${this.v2ApiUrl}/fill-template`, formData)
+      .pipe(
+        catchError(this.handleError)
+      );
+  }
+
   submitClaim(submissionData: ClaimSubmission, userId: string): Observable<ClaimRecord> {
     const headers = new HttpHeaders({
       'user-id': userId

+ 10 - 0
ai-data-entry-ui/src/app/services/extraction.ts

@@ -45,3 +45,13 @@ export interface ClaimRecord {
   declaration_signed: boolean;
   extraction_data?: ExtractionResponse | null;
 }
+
+export interface V2Field {
+  key: string;
+  value: string | number | null;
+}
+
+export interface V2TemplateResponse {
+  filled_data: V2Field[];
+  unfilled_fields: string[];
+}

+ 109 - 0
backend/crud.py

@@ -0,0 +1,109 @@
+import json
+from sqlalchemy.orm import Session
+from . import models, schemas
+import uuid
+from datetime import datetime
+
+def get_user(db: Session, user_id: str):
+    return db.query(models.User).filter(models.User.id == user_id).first()
+
+def get_users(db: Session):
+    return db.query(models.User).all()
+
+def create_user(db: Session, user: schemas.UserAccount):
+    db_user = models.User(
+        id=user.id,
+        name=user.name,
+        department=user.department,
+        medical_allowance=user.medical_allowance
+    )
+    db.add(db_user)
+    db.commit()
+    db.refresh(db_user)
+    return db_user
+
+def get_claims(db: Session):
+    return db.query(models.Claim).all()
+
+def create_claim(db: Session, claim: schemas.ClaimSubmission, user_id: str):
+    # Calculate amount claimed (payout)
+    user = get_user(db, user_id)
+    if not user:
+        return None
+        
+    spent_amount = claim.amount_spent
+    remaining = user.medical_allowance
+    amount_claimed = min(spent_amount, remaining)
+    
+    # Update User Balance
+    user.medical_allowance -= amount_claimed
+    
+    # Create DB model
+    db_claim = models.Claim(
+        id=str(uuid.uuid4()),
+        timestamp=datetime.now().isoformat(),
+        user_id=user_id,
+        amount_spent=spent_amount,
+        amount_claimed=amount_claimed,
+        provider_name=claim.provider_name,
+        visit_date=claim.visit_date,
+        treatment_type=claim.treatment_type,
+        cost_center=claim.cost_center,
+        declaration_signed=claim.declaration_signed,
+        extraction_data=json.dumps(claim.extraction_data.model_dump()) if claim.extraction_data else None
+    )
+    db.add(db_claim)
+    db.commit()
+    db.refresh(db_claim)
+    return db_claim
+
+def delete_claim(db: Session, claim_id: str):
+    db_claim = db.query(models.Claim).filter(models.Claim.id == claim_id).first()
+    if db_claim:
+        # Refund user allowance
+        user = get_user(db, db_claim.user_id)
+        if user:
+            user.medical_allowance += db_claim.amount_claimed
+            
+        db.delete(db_claim)
+        db.commit()
+        return True
+    return False
+
+def to_pydantic_claim(db_claim: models.Claim) -> schemas.ClaimRecord:
+    ext_data = None
+    if db_claim.extraction_data:
+        try:
+            ext_data = schemas.ExtractionResponse(**json.loads(db_claim.extraction_data))
+        except:
+            pass
+            
+    return schemas.ClaimRecord(
+        id=db_claim.id,
+        timestamp=db_claim.timestamp,
+        submitted_by=db_claim.owner.name if db_claim.owner else "Unknown",
+        department=db_claim.owner.department if db_claim.owner else "Unknown",
+        amount_spent=db_claim.amount_spent,
+        amount_claimed=db_claim.amount_claimed,
+        provider_name=db_claim.provider_name,
+        visit_date=db_claim.visit_date,
+        treatment_type=db_claim.treatment_type,
+        cost_center=db_claim.cost_center,
+        declaration_signed=db_claim.declaration_signed,
+        extraction_data=ext_data
+    )
+
+def seed_demo_user(db: Session):
+    user_id = "demo-user-123"
+    db_user = get_user(db, user_id)
+    if not db_user:
+        db_user = models.User(
+            id=user_id,
+            name="Demo User",
+            department="Operations",
+            medical_allowance=5000.0
+        )
+        db.add(db_user)
+        db.commit()
+        db.refresh(db_user)
+    return db_user

+ 15 - 0
backend/database.py

@@ -0,0 +1,15 @@
+from sqlalchemy import create_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+SQLALCHEMY_DATABASE_URL = "sqlite:///./data.db"
+
+# connect_args={"check_same_thread": False} is required only for SQLite. 
+# It's needed because SQLite only allows one thread to communicate with it, 
+# and FastAPI can use multiple threads per request.
+engine = create_engine(
+    SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
+)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()

+ 89 - 67
backend/main.py

@@ -1,49 +1,75 @@
 import os
 import uuid
+import json
 from datetime import datetime
-from fastapi import FastAPI, UploadFile, File, Header, HTTPException, status, Form
+from fastapi import FastAPI, UploadFile, File, Header, HTTPException, status, Form, Depends
 from fastapi.middleware.cors import CORSMiddleware
 from typing import Optional, List
-from backend.services.openai_service import extract_receipt_data
-from backend.schemas import ExtractionResponse, ClaimRecord, UserAccount, ClaimSubmission
+from sqlalchemy.orm import Session
+from backend.services.openai_service import extract_receipt_data, fill_form_with_template_v2
+from backend.schemas import ExtractionResponse, ClaimRecord, UserAccount, ClaimSubmission, V2TemplateResponse
+from . import crud, models, schemas
+from .database import SessionLocal, engine
 from dotenv import load_dotenv
 
 load_dotenv()
 
+# Create tables
+models.Base.metadata.create_all(bind=engine)
+
 app = FastAPI(title="AI-Assisted Data Entry API")
 
 # Configure CORS
 app.add_middleware(
     CORSMiddleware,
-    allow_origins=["*"],  # Adjust as needed for Angular frontend
+    allow_origins=["*"],
     allow_credentials=True,
     allow_methods=["*"],
     allow_headers=["*"],
 )
 
-# In-memory database
-CLAIMS_DB: List[ClaimRecord] = []
-USERS_DB: List[UserAccount] = [
-    UserAccount(
-        id="demo-user-123",
-        name="Demo User",
-        department="Operations",
-        medical_allowance=5000.0
-    )
-]
+# Dependency
+def get_db():
+    db = SessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()
+
+# Startup event for seeding
+@app.on_event("startup")
+def startup_event():
+    db = SessionLocal()
+    try:
+        crud.seed_demo_user(db)
+    finally:
+        db.close()
 
 @app.get("/health")
 async def health_check():
     return {"status": "healthy"}
 
 @app.get("/api/v1/users", response_model=List[UserAccount])
-async def get_users():
-    return USERS_DB
+async def get_users(db: Session = Depends(get_db)):
+    db_users = crud.get_users(db)
+    return [
+        UserAccount(
+            id=u.id, 
+            name=u.name, 
+            department=u.department, 
+            medical_allowance=u.medical_allowance
+        ) for u in db_users
+    ]
 
 @app.post("/api/v1/users", response_model=UserAccount)
-async def create_user(user: UserAccount):
-    USERS_DB.append(user)
-    return user
+async def create_user(user: UserAccount, db: Session = Depends(get_db)):
+    db_user = crud.create_user(db, user)
+    return UserAccount(
+        id=db_user.id,
+        name=db_user.name,
+        department=db_user.department,
+        medical_allowance=db_user.medical_allowance
+    )
 
 @app.post("/api/v1/extract", response_model=ExtractionResponse)
 async def extract_receipt(
@@ -74,66 +100,62 @@ async def extract_receipt(
             detail=f"An error occurred during extraction: {str(e)}"
         )
 
+@app.post("/api/v2/fill-template", response_model=V2TemplateResponse)
+async def fill_template(
+    file: UploadFile = File(...),
+    template_fields: str = Form(...),
+    user_name: str = Form("Demo User"),
+    department: str = Form("Operations")
+):
+    if not file.content_type.startswith("image/"):
+        raise HTTPException(
+            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+            detail="File provided is not an image."
+        )
+    
+    try:
+        content = await file.read()
+        template_dict = json.loads(template_fields)
+        
+        result = await fill_form_with_template_v2(
+            content, 
+            template_dict, 
+            user_name, 
+            department
+        )
+        
+        return result
+    except Exception as e:
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"An error occurred during template filling: {str(e)}"
+        )
+
 @app.post("/api/v1/claims", response_model=ClaimRecord)
 async def submit_claim(
     submission_data: ClaimSubmission,
-    user_id: str = Header(...)
+    user_id: str = Header(...),
+    db: Session = Depends(get_db)
 ):
-    # 1. Find User
-    user = next((u for u in USERS_DB if u.id == user_id), None)
-    if not user:
+    # crud operation handles user lookup and allowance update
+    db_claim = crud.create_claim(db, submission_data, user_id)
+    if not db_claim:
         raise HTTPException(status_code=404, detail="User not found")
-
-    # 2. Financial Guard Logic
-    spent_amount = submission_data.amount_spent
-    remaining = user.medical_allowance
-    amount_claimed = min(spent_amount, remaining)
-
-    # 3. Update User Balance
-    user.medical_allowance -= amount_claimed
-
-    # 4. Save Claim
-    claim = ClaimRecord(
-        id=str(uuid.uuid4()),
-        timestamp=datetime.now().isoformat(),
-        submitted_by=user.name,
-        department=user.department,
-        amount_spent=spent_amount,
-        amount_claimed=amount_claimed,
-        provider_name=submission_data.provider_name,
-        visit_date=submission_data.visit_date,
-        treatment_type=submission_data.treatment_type,
-        cost_center=submission_data.cost_center,
-        declaration_signed=submission_data.declaration_signed,
-        extraction_data=submission_data.extraction_data
-    )
-    CLAIMS_DB.append(claim)
-    return claim
+        
+    return crud.to_pydantic_claim(db_claim)
 
 @app.delete("/api/v1/claims/{claim_id}")
-async def delete_claim(claim_id: str):
-    global CLAIMS_DB, USERS_DB
-    
-    # 1. Find the claim
-    claim_index = next((i for i, c in enumerate(CLAIMS_DB) if c.id == claim_id), -1)
-    if claim_index == -1:
+async def delete_claim(claim_id: str, db: Session = Depends(get_db)):
+    success = crud.delete_claim(db, claim_id)
+    if not success:
         raise HTTPException(status_code=404, detail="Claim not found")
-    
-    deleted_claim = CLAIMS_DB[claim_index]
-    
-    # 2. Refund the user's allowance if user exists
-    user = next((u for u in USERS_DB if u.name == deleted_claim.submitted_by), None)
-    if user:
-        user.medical_allowance += deleted_claim.amount_claimed
         
-    # 3. Remove from list
-    CLAIMS_DB.pop(claim_index)
-    
     return {"status": "success", "message": "Claim deleted and allowance refunded"}
 
 @app.get("/api/v1/claims", response_model=List[ClaimRecord])
-async def get_claims():
-    return CLAIMS_DB
+async def get_claims(db: Session = Depends(get_db)):
+    db_claims = crud.get_claims(db)
+    return [crud.to_pydantic_claim(c) for c in db_claims]
 
 if __name__ == "__main__":
     import uvicorn

+ 30 - 0
backend/models.py

@@ -0,0 +1,30 @@
+from sqlalchemy import Boolean, Column, ForeignKey, String, Float, Text
+from sqlalchemy.orm import relationship
+from .database import Base
+
+class User(Base):
+    __tablename__ = "users"
+
+    id = Column(String, primary_key=True, index=True)
+    name = Column(String)
+    department = Column(String)
+    medical_allowance = Column(Float)
+
+    claims = relationship("Claim", back_populates="owner")
+
+class Claim(Base):
+    __tablename__ = "claims"
+
+    id = Column(String, primary_key=True, index=True)
+    timestamp = Column(String)
+    user_id = Column(String, ForeignKey("users.id"))
+    amount_spent = Column(Float)
+    amount_claimed = Column(Float)
+    provider_name = Column(String)
+    visit_date = Column(String)
+    treatment_type = Column(String)
+    cost_center = Column(String)
+    declaration_signed = Column(Boolean)
+    extraction_data = Column(Text) # Stored as JSON string
+
+    owner = relationship("User", back_populates="claims")

+ 14 - 1
backend/schemas.py

@@ -1,5 +1,5 @@
 from pydantic import BaseModel, Field
-from typing import List, Optional
+from typing import List, Optional, Union, Dict
 from uuid import UUID
 from datetime import datetime
 
@@ -46,3 +46,16 @@ class ClaimRecord(BaseModel):
     cost_center: str = Field(description="Internal cost center code.")
     declaration_signed: bool = Field(description="Whether the user signed the compliance declaration.")
     extraction_data: Optional[ExtractionResponse] = Field(default=None, description="The optional AI-extracted data for this claim.")
+
+class V2TemplateRequest(BaseModel):
+    template_fields: dict[str, str] = Field(description="Mapping of field keys to their descriptions/instructions.")
+    user_name: Optional[str] = "Demo User"
+    department: Optional[str] = "Operations"
+
+class V2Field(BaseModel):
+    key: str = Field(description="The field name from the template.")
+    value: Optional[Union[str, float]] = Field(description="The extracted value or null if not found.")
+
+class V2TemplateResponse(BaseModel):
+    filled_data: List[V2Field] = Field(description="The list of filled form values where keys match the template.")
+    unfilled_fields: List[str] = Field(description="Fields from the template that could not be found in the image.")

+ 54 - 1
backend/services/openai_service.py

@@ -2,10 +2,11 @@ import os
 import base64
 import io
 import logging
+import json
 from openai import AsyncOpenAI
 from dotenv import load_dotenv
 from PIL import Image
-from backend.schemas import ExtractionResponse
+from backend.schemas import ExtractionResponse, V2TemplateResponse
 
 load_dotenv()
 
@@ -87,3 +88,55 @@ async def extract_receipt_data(image_content: bytes, user_name: str, department:
             logger.warning(f"Manual review required for receipt submitted by {user_name}")
             
     return result
+
+async def fill_form_with_template_v2(image_content: bytes, template_fields: dict, user_name: str, department: str) -> V2TemplateResponse:
+    # 1. Compress Image
+    compressed_content = compress_image(image_content)
+    base64_image = base64.b64encode(compressed_content).decode("utf-8")
+    
+    # 2. V2 Prompt
+    template_json = json.dumps(template_fields, indent=2)
+    prompt = (
+        f"You are a professional Data Entry Clerk helping an HR department in Malaysia. "
+        f"You will receive a medical receipt image and a Form Template consisting of specific field names and descriptions. "
+        f"Your task is to fill the form values based ONLY on the evidence in the image. "
+        f"The employee is {user_name} from {department}. "
+        f"FORM TEMPLATE (JSON): {template_json}\n\n"
+        f"STRICT RULES:\n"
+        f"1. If a field in the template is not explicitly visible or is ambiguous, you MUST return `null`. Do not guess.\n"
+        f"2. For currency, assume MYR unless stated otherwise.\n"
+        f"3. If the user's name ({user_name}) is not on the receipt, leave any name-related fields `null`.\n"
+        f"4. For any field identified, provide a clean value (e.g. string or float).\n"
+        f"5. Return the result as a structured object with `filled_data` (a list of objects each containing `key` and `value`) "
+        f"and `unfilled_fields` (a list of keys from the template for which no evidence was found)."
+    )
+    
+    # 3. Async Extraction
+    completion = await client.beta.chat.completions.parse(
+        model="gpt-4o-mini",
+        messages=[
+            {
+                "role": "system",
+                "content": "You are a professional Data Entry Clerk. Extract data accurately based on a provided template."
+            },
+            {
+                "role": "user",
+                "content": [
+                    {"type": "text", "text": prompt},
+                    {
+                        "type": "image_url",
+                        "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
+                    },
+                ],
+            }
+        ],
+        response_format=V2TemplateResponse,
+    )
+    
+    result = completion.choices[0].message.parsed
+    
+    # 4. Logging for Demo
+    if result:
+        logger.info(f"V2 Extraction complete for {user_name}. Fields filled: {len(result.filled_data)}")
+            
+    return result

+ 1 - 0
requirements.txt

@@ -5,3 +5,4 @@ python-dotenv
 python-multipart
 pydantic>=2.0
 Pillow
+sqlalchemy