Spaces:
Sleeping
Sleeping
Commit ·
6ec2d12
1
Parent(s): e247414
Add document comparison, bulk validation, and projects features
Browse files- .gitignore +15 -0
- app/database.py +190 -0
- app/main.py +423 -6
- app/static/index.html +855 -44
- app/templates.json +1669 -196
- app/validator.py +513 -50
- requirements.txt +5 -0
.gitignore
CHANGED
|
@@ -44,3 +44,18 @@ Thumbs.db
|
|
| 44 |
|
| 45 |
|
| 46 |
extracted_images_log/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
extracted_images_log/
|
| 47 |
+
|
| 48 |
+
# Virtual environments (local)
|
| 49 |
+
validatorvenv/
|
| 50 |
+
|
| 51 |
+
# SQLite database (local data)
|
| 52 |
+
projects.db
|
| 53 |
+
|
| 54 |
+
# Debug/test files
|
| 55 |
+
debug_*.py
|
| 56 |
+
analyze_*.py
|
| 57 |
+
|
| 58 |
+
# Test documents
|
| 59 |
+
*.pdf
|
| 60 |
+
*.docx
|
| 61 |
+
*.pptx
|
app/database.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SQLite database layer for Projects feature.
|
| 3 |
+
Manages projects and validation history.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sqlite3
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List, Dict, Optional, Any
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ProjectDatabase:
|
| 17 |
+
"""Database manager for projects and validations."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, db_path: str = "projects.db"):
|
| 20 |
+
"""Initialize database connection and create tables if needed."""
|
| 21 |
+
self.db_path = db_path
|
| 22 |
+
self._init_database()
|
| 23 |
+
|
| 24 |
+
def _init_database(self):
|
| 25 |
+
"""Create tables if they don't exist."""
|
| 26 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 27 |
+
cursor = conn.cursor()
|
| 28 |
+
|
| 29 |
+
# Projects table
|
| 30 |
+
cursor.execute("""
|
| 31 |
+
CREATE TABLE IF NOT EXISTS projects (
|
| 32 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 33 |
+
name TEXT NOT NULL UNIQUE,
|
| 34 |
+
description TEXT,
|
| 35 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 36 |
+
)
|
| 37 |
+
""")
|
| 38 |
+
|
| 39 |
+
# Validations table
|
| 40 |
+
cursor.execute("""
|
| 41 |
+
CREATE TABLE IF NOT EXISTS validations (
|
| 42 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 43 |
+
project_id INTEGER,
|
| 44 |
+
validation_type TEXT NOT NULL,
|
| 45 |
+
template_key TEXT,
|
| 46 |
+
filename TEXT,
|
| 47 |
+
status TEXT,
|
| 48 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 49 |
+
results_json TEXT,
|
| 50 |
+
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
| 51 |
+
)
|
| 52 |
+
""")
|
| 53 |
+
|
| 54 |
+
conn.commit()
|
| 55 |
+
logger.info(f"Database initialized at {self.db_path}")
|
| 56 |
+
|
| 57 |
+
# Project CRUD operations
|
| 58 |
+
|
| 59 |
+
def create_project(self, name: str, description: str = "") -> int:
|
| 60 |
+
"""Create a new project."""
|
| 61 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 62 |
+
cursor = conn.cursor()
|
| 63 |
+
try:
|
| 64 |
+
cursor.execute(
|
| 65 |
+
"INSERT INTO projects (name, description) VALUES (?, ?)",
|
| 66 |
+
(name, description)
|
| 67 |
+
)
|
| 68 |
+
conn.commit()
|
| 69 |
+
project_id = cursor.lastrowid
|
| 70 |
+
logger.info(f"Created project: {name} (ID: {project_id})")
|
| 71 |
+
return project_id
|
| 72 |
+
except sqlite3.IntegrityError:
|
| 73 |
+
raise ValueError(f"Project '{name}' already exists")
|
| 74 |
+
|
| 75 |
+
def list_projects(self) -> List[Dict[str, Any]]:
|
| 76 |
+
"""List all projects."""
|
| 77 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 78 |
+
conn.row_factory = sqlite3.Row
|
| 79 |
+
cursor = conn.cursor()
|
| 80 |
+
cursor.execute("""
|
| 81 |
+
SELECT p.*, COUNT(v.id) as validation_count
|
| 82 |
+
FROM projects p
|
| 83 |
+
LEFT JOIN validations v ON p.id = v.project_id
|
| 84 |
+
GROUP BY p.id
|
| 85 |
+
ORDER BY p.created_at DESC
|
| 86 |
+
""")
|
| 87 |
+
projects = [dict(row) for row in cursor.fetchall()]
|
| 88 |
+
return projects
|
| 89 |
+
|
| 90 |
+
def get_project(self, project_id: int) -> Optional[Dict[str, Any]]:
|
| 91 |
+
"""Get a specific project by ID."""
|
| 92 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 93 |
+
conn.row_factory = sqlite3.Row
|
| 94 |
+
cursor = conn.cursor()
|
| 95 |
+
cursor.execute("""
|
| 96 |
+
SELECT p.*, COUNT(v.id) as validation_count
|
| 97 |
+
FROM projects p
|
| 98 |
+
LEFT JOIN validations v ON p.id = v.project_id
|
| 99 |
+
WHERE p.id = ?
|
| 100 |
+
GROUP BY p.id
|
| 101 |
+
""", (project_id,))
|
| 102 |
+
row = cursor.fetchone()
|
| 103 |
+
return dict(row) if row else None
|
| 104 |
+
|
| 105 |
+
def delete_project(self, project_id: int) -> bool:
|
| 106 |
+
"""Delete a project and all associated validations."""
|
| 107 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 108 |
+
cursor = conn.cursor()
|
| 109 |
+
cursor.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
| 110 |
+
conn.commit()
|
| 111 |
+
deleted = cursor.rowcount > 0
|
| 112 |
+
if deleted:
|
| 113 |
+
logger.info(f"Deleted project ID: {project_id}")
|
| 114 |
+
return deleted
|
| 115 |
+
|
| 116 |
+
# Validation operations
|
| 117 |
+
|
| 118 |
+
def save_validation(
|
| 119 |
+
self,
|
| 120 |
+
project_id: Optional[int],
|
| 121 |
+
validation_type: str,
|
| 122 |
+
filename: str,
|
| 123 |
+
status: str,
|
| 124 |
+
results: Dict[str, Any],
|
| 125 |
+
template_key: Optional[str] = None
|
| 126 |
+
) -> int:
|
| 127 |
+
"""Save a validation result to a project."""
|
| 128 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 129 |
+
cursor = conn.cursor()
|
| 130 |
+
cursor.execute("""
|
| 131 |
+
INSERT INTO validations
|
| 132 |
+
(project_id, validation_type, template_key, filename, status, results_json)
|
| 133 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 134 |
+
""", (
|
| 135 |
+
project_id,
|
| 136 |
+
validation_type,
|
| 137 |
+
template_key,
|
| 138 |
+
filename,
|
| 139 |
+
status,
|
| 140 |
+
json.dumps(results)
|
| 141 |
+
))
|
| 142 |
+
conn.commit()
|
| 143 |
+
validation_id = cursor.lastrowid
|
| 144 |
+
logger.info(f"Saved {validation_type} validation (ID: {validation_id}) to project {project_id}")
|
| 145 |
+
return validation_id
|
| 146 |
+
|
| 147 |
+
def get_project_validations(self, project_id: int) -> List[Dict[str, Any]]:
|
| 148 |
+
"""Get all validations for a specific project."""
|
| 149 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 150 |
+
conn.row_factory = sqlite3.Row
|
| 151 |
+
cursor = conn.cursor()
|
| 152 |
+
cursor.execute("""
|
| 153 |
+
SELECT * FROM validations
|
| 154 |
+
WHERE project_id = ?
|
| 155 |
+
ORDER BY created_at DESC
|
| 156 |
+
""", (project_id,))
|
| 157 |
+
validations = []
|
| 158 |
+
for row in cursor.fetchall():
|
| 159 |
+
validation = dict(row)
|
| 160 |
+
# Parse JSON results
|
| 161 |
+
if validation['results_json']:
|
| 162 |
+
validation['results'] = json.loads(validation['results_json'])
|
| 163 |
+
del validation['results_json']
|
| 164 |
+
validations.append(validation)
|
| 165 |
+
return validations
|
| 166 |
+
|
| 167 |
+
def get_recent_validations(self, limit: int = 50) -> List[Dict[str, Any]]:
|
| 168 |
+
"""Get recent validations across all projects."""
|
| 169 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 170 |
+
conn.row_factory = sqlite3.Row
|
| 171 |
+
cursor = conn.cursor()
|
| 172 |
+
cursor.execute("""
|
| 173 |
+
SELECT v.*, p.name as project_name
|
| 174 |
+
FROM validations v
|
| 175 |
+
LEFT JOIN projects p ON v.project_id = p.id
|
| 176 |
+
ORDER BY v.created_at DESC
|
| 177 |
+
LIMIT ?
|
| 178 |
+
""", (limit,))
|
| 179 |
+
validations = []
|
| 180 |
+
for row in cursor.fetchall():
|
| 181 |
+
validation = dict(row)
|
| 182 |
+
if validation['results_json']:
|
| 183 |
+
validation['results'] = json.loads(validation['results_json'])
|
| 184 |
+
del validation['results_json']
|
| 185 |
+
validations.append(validation)
|
| 186 |
+
return validations
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# Global database instance
|
| 190 |
+
db = ProjectDatabase()
|
app/main.py
CHANGED
|
@@ -7,7 +7,7 @@ To run this application:
|
|
| 7 |
3. Run the server: uvicorn app.main:app --reload
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
from fastapi import FastAPI, File, UploadFile, HTTPException, Query
|
| 11 |
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
from pydantic import BaseModel
|
|
@@ -28,7 +28,9 @@ logging.basicConfig(
|
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
| 30 |
from app.validator import Validator, load_templates, get_template, extract_images_from_document
|
|
|
|
| 31 |
|
|
|
|
| 32 |
app = FastAPI(
|
| 33 |
title="Medical Document Validator API",
|
| 34 |
description="API for validating medical documents against predefined templates using LLM",
|
|
@@ -65,7 +67,7 @@ class SpellCheckError(BaseModel):
|
|
| 65 |
word: str
|
| 66 |
context: str
|
| 67 |
suggestions: List[str]
|
| 68 |
-
error_type: str # "spelling", "grammar", "typo"
|
| 69 |
confidence: float
|
| 70 |
|
| 71 |
|
|
@@ -76,6 +78,15 @@ class SpellCheckReport(BaseModel):
|
|
| 76 |
summary: str
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
class ValidationReport(BaseModel):
|
| 80 |
"""Complete validation report response."""
|
| 81 |
template_key: str
|
|
@@ -83,6 +94,68 @@ class ValidationReport(BaseModel):
|
|
| 83 |
summary: str
|
| 84 |
elements_report: List[ElementReport]
|
| 85 |
spell_check: Optional[SpellCheckReport] = None # Optional spell check results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
# API Endpoints
|
|
@@ -131,7 +204,8 @@ async def get_templates():
|
|
| 131 |
async def validate_document(
|
| 132 |
file: UploadFile = File(..., description="Document file to validate (PDF, DOCX, or PPTX)"),
|
| 133 |
template_key: str = Query(..., description="Template key to validate against"),
|
| 134 |
-
check_spelling: bool = Query(False, description="Enable spell checking (ignores proper names)")
|
|
|
|
| 135 |
):
|
| 136 |
"""
|
| 137 |
Validate a document against a specified template.
|
|
@@ -183,10 +257,11 @@ async def validate_document(
|
|
| 183 |
|
| 184 |
# Perform validation
|
| 185 |
try:
|
| 186 |
-
validation_report = validator.validate_document(
|
| 187 |
file_content=file_content,
|
| 188 |
file_extension=file_extension,
|
| 189 |
-
template_key=template_key
|
|
|
|
| 190 |
)
|
| 191 |
|
| 192 |
# Convert to Pydantic model for response validation
|
|
@@ -194,6 +269,11 @@ async def validate_document(
|
|
| 194 |
ElementReport(**elem) for elem in validation_report.get("elements_report", [])
|
| 195 |
]
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
# Perform spell checking if requested
|
| 198 |
spell_check_result = None
|
| 199 |
if check_spelling:
|
|
@@ -226,7 +306,8 @@ async def validate_document(
|
|
| 226 |
status=validation_report.get("status", "FAIL"),
|
| 227 |
summary=validation_report.get("summary", ""),
|
| 228 |
elements_report=elements_report,
|
| 229 |
-
spell_check=spell_check_result
|
|
|
|
| 230 |
)
|
| 231 |
|
| 232 |
except ValueError as e:
|
|
@@ -241,6 +322,91 @@ async def validate_document(
|
|
| 241 |
)
|
| 242 |
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
@app.get("/health", tags=["Health"])
|
| 245 |
async def health_check():
|
| 246 |
"""Health check endpoint."""
|
|
@@ -408,6 +574,257 @@ async def debug_extract_images(
|
|
| 408 |
)
|
| 409 |
|
| 410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
if __name__ == "__main__":
|
| 412 |
import uvicorn
|
| 413 |
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
|
|
|
| 7 |
3. Run the server: uvicorn app.main:app --reload
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Form
|
| 11 |
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
from pydantic import BaseModel
|
|
|
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
| 30 |
from app.validator import Validator, load_templates, get_template, extract_images_from_document
|
| 31 |
+
from app.database import db
|
| 32 |
|
| 33 |
+
# Load environment variables
|
| 34 |
app = FastAPI(
|
| 35 |
title="Medical Document Validator API",
|
| 36 |
description="API for validating medical documents against predefined templates using LLM",
|
|
|
|
| 67 |
word: str
|
| 68 |
context: str
|
| 69 |
suggestions: List[str]
|
| 70 |
+
error_type: str # "spelling", "grammar", "formatting", "typo"
|
| 71 |
confidence: float
|
| 72 |
|
| 73 |
|
|
|
|
| 78 |
summary: str
|
| 79 |
|
| 80 |
|
| 81 |
+
class LinkReport(BaseModel):
|
| 82 |
+
"""Link validation report."""
|
| 83 |
+
url: str
|
| 84 |
+
status: str
|
| 85 |
+
status_code: int
|
| 86 |
+
message: str
|
| 87 |
+
page: str
|
| 88 |
+
|
| 89 |
+
|
| 90 |
class ValidationReport(BaseModel):
|
| 91 |
"""Complete validation report response."""
|
| 92 |
template_key: str
|
|
|
|
| 94 |
summary: str
|
| 95 |
elements_report: List[ElementReport]
|
| 96 |
spell_check: Optional[SpellCheckReport] = None # Optional spell check results
|
| 97 |
+
link_report: Optional[List[LinkReport]] = None # Optional link validation results
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class ComparisonChange(BaseModel):
|
| 101 |
+
"""Individual change detected in comparison."""
|
| 102 |
+
type: str # "addition", "deletion", "modification"
|
| 103 |
+
section: Optional[str] = None # Section/area where change occurred
|
| 104 |
+
description: str # Description of the change
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class ComparisonReport(BaseModel):
|
| 108 |
+
"""Document comparison report."""
|
| 109 |
+
summary: str # Natural language summary of changes
|
| 110 |
+
changes: List[ComparisonChange] # Detailed list of changes
|
| 111 |
+
file1_name: str
|
| 112 |
+
file2_name: str
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class BulkValidationDetail(BaseModel):
|
| 116 |
+
"""Individual validation result for bulk certificate validation."""
|
| 117 |
+
name: str
|
| 118 |
+
status: str # "exact_match", "fuzzy_match", "missing", "extra"
|
| 119 |
+
certificate_file: Optional[str] = None
|
| 120 |
+
similarity: Optional[int] = None # Percentage for fuzzy matches
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class BulkValidationResult(BaseModel):
|
| 124 |
+
"""Bulk certificate validation result."""
|
| 125 |
+
total_names: int
|
| 126 |
+
total_certificates: int
|
| 127 |
+
exact_matches: int
|
| 128 |
+
fuzzy_matches: int
|
| 129 |
+
missing: int
|
| 130 |
+
extras: int
|
| 131 |
+
details: List[BulkValidationDetail]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class Project(BaseModel):
|
| 135 |
+
"""Project model."""
|
| 136 |
+
id: int
|
| 137 |
+
name: str
|
| 138 |
+
description: Optional[str] = ""
|
| 139 |
+
created_at: str
|
| 140 |
+
validation_count: int = 0
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class ProjectCreate(BaseModel):
|
| 144 |
+
"""Project creation request."""
|
| 145 |
+
name: str
|
| 146 |
+
description: str = ""
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class ValidationHistory(BaseModel):
|
| 150 |
+
"""Validation history item."""
|
| 151 |
+
id: int
|
| 152 |
+
project_id: Optional[int]
|
| 153 |
+
project_name: Optional[str]
|
| 154 |
+
validation_type: str
|
| 155 |
+
template_key: Optional[str]
|
| 156 |
+
filename: str
|
| 157 |
+
status: str
|
| 158 |
+
created_at: str
|
| 159 |
|
| 160 |
|
| 161 |
# API Endpoints
|
|
|
|
| 204 |
async def validate_document(
|
| 205 |
file: UploadFile = File(..., description="Document file to validate (PDF, DOCX, or PPTX)"),
|
| 206 |
template_key: str = Query(..., description="Template key to validate against"),
|
| 207 |
+
check_spelling: bool = Query(False, description="Enable spell checking (ignores proper names)"),
|
| 208 |
+
custom_prompt: Optional[str] = Query(None, description="Optional custom instructions to adapt validation")
|
| 209 |
):
|
| 210 |
"""
|
| 211 |
Validate a document against a specified template.
|
|
|
|
| 257 |
|
| 258 |
# Perform validation
|
| 259 |
try:
|
| 260 |
+
validation_report = await validator.validate_document(
|
| 261 |
file_content=file_content,
|
| 262 |
file_extension=file_extension,
|
| 263 |
+
template_key=template_key,
|
| 264 |
+
custom_prompt=custom_prompt
|
| 265 |
)
|
| 266 |
|
| 267 |
# Convert to Pydantic model for response validation
|
|
|
|
| 269 |
ElementReport(**elem) for elem in validation_report.get("elements_report", [])
|
| 270 |
]
|
| 271 |
|
| 272 |
+
# Convert link report to Pydantic models
|
| 273 |
+
link_report = [
|
| 274 |
+
LinkReport(**link) for link in validation_report.get("link_report", [])
|
| 275 |
+
]
|
| 276 |
+
|
| 277 |
# Perform spell checking if requested
|
| 278 |
spell_check_result = None
|
| 279 |
if check_spelling:
|
|
|
|
| 306 |
status=validation_report.get("status", "FAIL"),
|
| 307 |
summary=validation_report.get("summary", ""),
|
| 308 |
elements_report=elements_report,
|
| 309 |
+
spell_check=spell_check_result,
|
| 310 |
+
link_report=link_report
|
| 311 |
)
|
| 312 |
|
| 313 |
except ValueError as e:
|
|
|
|
| 322 |
)
|
| 323 |
|
| 324 |
|
| 325 |
+
@app.post("/validate/spelling-only", tags=["Validation"])
|
| 326 |
+
async def validate_spelling_only(
|
| 327 |
+
file: UploadFile = File(..., description="Document file to check spelling (PDF, DOCX, or PPTX)")
|
| 328 |
+
):
|
| 329 |
+
"""
|
| 330 |
+
Check spelling in a document without template validation.
|
| 331 |
+
|
| 332 |
+
Args:
|
| 333 |
+
file: Uploaded document file (PDF, DOCX, or PPTX)
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
Spell check report only
|
| 337 |
+
|
| 338 |
+
Raises:
|
| 339 |
+
400: Bad request (unsupported format)
|
| 340 |
+
422: Unprocessable entity (extraction failure)
|
| 341 |
+
500: Internal server error
|
| 342 |
+
"""
|
| 343 |
+
# Validate file extension
|
| 344 |
+
filename = file.filename or ""
|
| 345 |
+
file_extension = Path(filename).suffix.lower()
|
| 346 |
+
supported_extensions = [".pdf", ".docx", ".pptx"]
|
| 347 |
+
|
| 348 |
+
if file_extension not in supported_extensions:
|
| 349 |
+
raise HTTPException(
|
| 350 |
+
status_code=400,
|
| 351 |
+
detail=f"Unsupported file format: {file_extension}. Supported formats: {', '.join(supported_extensions)}"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
# Read file content
|
| 355 |
+
try:
|
| 356 |
+
file_content = await file.read()
|
| 357 |
+
if not file_content:
|
| 358 |
+
raise HTTPException(
|
| 359 |
+
status_code=400,
|
| 360 |
+
detail="Uploaded file is empty"
|
| 361 |
+
)
|
| 362 |
+
except Exception as e:
|
| 363 |
+
raise HTTPException(
|
| 364 |
+
status_code=400,
|
| 365 |
+
detail=f"Failed to read file: {str(e)}"
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Extract text and perform spell checking only
|
| 369 |
+
try:
|
| 370 |
+
from app.validator import extract_document_text
|
| 371 |
+
|
| 372 |
+
# Extract text from document
|
| 373 |
+
try:
|
| 374 |
+
document_text = extract_document_text(file_content, file_extension)
|
| 375 |
+
except Exception as e:
|
| 376 |
+
raise HTTPException(
|
| 377 |
+
status_code=422,
|
| 378 |
+
detail=f"Failed to extract text from document: {str(e)}"
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
# Perform spell checking
|
| 382 |
+
spell_check_data = validator.check_spelling(document_text)
|
| 383 |
+
|
| 384 |
+
# Convert to Pydantic model
|
| 385 |
+
spell_errors = [
|
| 386 |
+
SpellCheckError(**error) for error in spell_check_data.get("errors", [])
|
| 387 |
+
]
|
| 388 |
+
spell_check_result = SpellCheckReport(
|
| 389 |
+
total_errors=spell_check_data.get("total_errors", 0),
|
| 390 |
+
errors=spell_errors,
|
| 391 |
+
summary=spell_check_data.get("summary", "")
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
# Return spelling-only response
|
| 395 |
+
return {
|
| 396 |
+
"mode": "spelling_only",
|
| 397 |
+
"spell_check": spell_check_result
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
except HTTPException:
|
| 401 |
+
raise
|
| 402 |
+
except Exception as e:
|
| 403 |
+
logger.error(f"Spell check failed: {str(e)}", exc_info=True)
|
| 404 |
+
raise HTTPException(
|
| 405 |
+
status_code=500,
|
| 406 |
+
detail=f"Internal server error during spell checking: {str(e)}"
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
@app.get("/health", tags=["Health"])
|
| 411 |
async def health_check():
|
| 412 |
"""Health check endpoint."""
|
|
|
|
| 574 |
)
|
| 575 |
|
| 576 |
|
| 577 |
+
@app.post("/compare", response_model=ComparisonReport, tags=["Comparison"])
|
| 578 |
+
async def compare_documents(
|
| 579 |
+
file1: UploadFile = File(..., description="First document (original version)"),
|
| 580 |
+
file2: UploadFile = File(..., description="Second document (modified version)")
|
| 581 |
+
):
|
| 582 |
+
"""
|
| 583 |
+
Compare two document versions using LLM to identify changes.
|
| 584 |
+
|
| 585 |
+
Args:
|
| 586 |
+
file1: Original document
|
| 587 |
+
file2: Modified document
|
| 588 |
+
|
| 589 |
+
Returns:
|
| 590 |
+
Comparison report with summary and detailed changes
|
| 591 |
+
"""
|
| 592 |
+
# Validate file extensions
|
| 593 |
+
filename1 = file1.filename or ""
|
| 594 |
+
filename2 = file2.filename or ""
|
| 595 |
+
ext1 = Path(filename1).suffix.lower()
|
| 596 |
+
ext2 = Path(filename2).suffix.lower()
|
| 597 |
+
supported_extensions = [".pdf", ".docx", ".pptx"]
|
| 598 |
+
|
| 599 |
+
if ext1 not in supported_extensions or ext2 not in supported_extensions:
|
| 600 |
+
raise HTTPException(
|
| 601 |
+
status_code=400,
|
| 602 |
+
detail=f"Unsupported file format. Supported: {', '.join(supported_extensions)}"
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
# Read file contents
|
| 606 |
+
try:
|
| 607 |
+
content1 = await file1.read()
|
| 608 |
+
content2 = await file2.read()
|
| 609 |
+
|
| 610 |
+
if not content1 or not content2:
|
| 611 |
+
raise HTTPException(status_code=400, detail="One or both files are empty")
|
| 612 |
+
except Exception as e:
|
| 613 |
+
raise HTTPException(status_code=400, detail=f"Failed to read files: {str(e)}")
|
| 614 |
+
|
| 615 |
+
# Perform comparison using validator
|
| 616 |
+
try:
|
| 617 |
+
comparison_result = await validator.compare_documents(
|
| 618 |
+
file1_content=content1,
|
| 619 |
+
file1_extension=ext1,
|
| 620 |
+
file1_name=filename1,
|
| 621 |
+
file2_content=content2,
|
| 622 |
+
file2_extension=ext2,
|
| 623 |
+
file2_name=filename2
|
| 624 |
+
)
|
| 625 |
+
|
| 626 |
+
# Convert to Pydantic models
|
| 627 |
+
changes = [
|
| 628 |
+
ComparisonChange(**change) for change in comparison_result.get("changes", [])
|
| 629 |
+
]
|
| 630 |
+
|
| 631 |
+
return ComparisonReport(
|
| 632 |
+
summary=comparison_result.get("summary", "No summary available"),
|
| 633 |
+
changes=changes,
|
| 634 |
+
file1_name=filename1,
|
| 635 |
+
file2_name=filename2
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
except Exception as e:
|
| 639 |
+
logger.error(f"Comparison failed: {str(e)}", exc_info=True)
|
| 640 |
+
raise HTTPException(
|
| 641 |
+
status_code=500,
|
| 642 |
+
detail=f"Comparison failed: {str(e)}"
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
@app.post("/excel-columns", tags=["Bulk Validation"])
|
| 647 |
+
async def get_excel_columns(file: UploadFile = File(...)):
|
| 648 |
+
"""
|
| 649 |
+
Extract column headers from an Excel file.
|
| 650 |
+
|
| 651 |
+
Args:
|
| 652 |
+
file: Excel file (.xlsx)
|
| 653 |
+
|
| 654 |
+
Returns:
|
| 655 |
+
List of column names and row count
|
| 656 |
+
"""
|
| 657 |
+
try:
|
| 658 |
+
import openpyxl
|
| 659 |
+
from io import BytesIO
|
| 660 |
+
|
| 661 |
+
content = await file.read()
|
| 662 |
+
wb = openpyxl.load_workbook(BytesIO(content))
|
| 663 |
+
ws = wb.active
|
| 664 |
+
|
| 665 |
+
# Get first row as headers
|
| 666 |
+
headers = []
|
| 667 |
+
for cell in ws[1]:
|
| 668 |
+
if cell.value:
|
| 669 |
+
headers.append(str(cell.value))
|
| 670 |
+
|
| 671 |
+
row_count = ws.max_row - 1 # Exclude header row
|
| 672 |
+
|
| 673 |
+
return {
|
| 674 |
+
"columns": headers,
|
| 675 |
+
"row_count": row_count
|
| 676 |
+
}
|
| 677 |
+
except Exception as e:
|
| 678 |
+
raise HTTPException(
|
| 679 |
+
status_code=400,
|
| 680 |
+
detail=f"Failed to parse Excel file: {str(e)}"
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
@app.post("/bulk-validate", response_model=BulkValidationResult, tags=["Bulk Validation"])
|
| 685 |
+
async def bulk_validate_certificates(
|
| 686 |
+
excel_file: UploadFile = File(..., description="Excel file with names"),
|
| 687 |
+
name_column: str = Form(..., description="Column name containing names"),
|
| 688 |
+
certificate_files: List[UploadFile] = File(..., description="Certificate files (max 150)")
|
| 689 |
+
):
|
| 690 |
+
"""
|
| 691 |
+
Validate multiple certificates against an Excel list of names.
|
| 692 |
+
|
| 693 |
+
Args:
|
| 694 |
+
excel_file: Excel file with attendee names
|
| 695 |
+
name_column: Column containing the names
|
| 696 |
+
certificate_files: List of certificate files to validate
|
| 697 |
+
|
| 698 |
+
Returns:
|
| 699 |
+
Bulk validation results with matches, missing, and extras
|
| 700 |
+
"""
|
| 701 |
+
if len(certificate_files) > 150:
|
| 702 |
+
raise HTTPException(
|
| 703 |
+
status_code=400,
|
| 704 |
+
detail="Maximum 150 certificates allowed"
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
try:
|
| 708 |
+
# Read Excel file
|
| 709 |
+
excel_content = await excel_file.read()
|
| 710 |
+
|
| 711 |
+
# Read all certificate files
|
| 712 |
+
cert_data = []
|
| 713 |
+
for cert_file in certificate_files:
|
| 714 |
+
content = await cert_file.read()
|
| 715 |
+
filename = cert_file.filename or "unknown"
|
| 716 |
+
ext = Path(filename).suffix.lower()
|
| 717 |
+
cert_data.append((filename, content, ext))
|
| 718 |
+
|
| 719 |
+
# Call validator
|
| 720 |
+
result = await validator.bulk_validate_certificates(
|
| 721 |
+
excel_content=excel_content,
|
| 722 |
+
name_column=name_column,
|
| 723 |
+
certificate_data=cert_data
|
| 724 |
+
)
|
| 725 |
+
|
| 726 |
+
# Convert to Pydantic models
|
| 727 |
+
details = [
|
| 728 |
+
BulkValidationDetail(**detail) for detail in result.get("details", [])
|
| 729 |
+
]
|
| 730 |
+
|
| 731 |
+
return BulkValidationResult(
|
| 732 |
+
total_names=result.get("total_names", 0),
|
| 733 |
+
total_certificates=result.get("total_certificates", 0),
|
| 734 |
+
exact_matches=result.get("exact_matches", 0),
|
| 735 |
+
fuzzy_matches=result.get("fuzzy_matches", 0),
|
| 736 |
+
missing=result.get("missing", 0),
|
| 737 |
+
extras=result.get("extras", 0),
|
| 738 |
+
details=details
|
| 739 |
+
)
|
| 740 |
+
|
| 741 |
+
except Exception as e:
|
| 742 |
+
logger.error(f"Bulk validation failed: {str(e)}", exc_info=True)
|
| 743 |
+
raise HTTPException(
|
| 744 |
+
status_code=500,
|
| 745 |
+
detail=f"Bulk validation failed: {str(e)}"
|
| 746 |
+
)
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
# ==================== PROJECTS ENDPOINTS ====================
|
| 750 |
+
|
| 751 |
+
@app.get("/projects", response_model=List[Project], tags=["Projects"])
|
| 752 |
+
async def list_projects():
|
| 753 |
+
"""List all projects."""
|
| 754 |
+
try:
|
| 755 |
+
projects = db.list_projects()
|
| 756 |
+
return projects
|
| 757 |
+
except Exception as e:
|
| 758 |
+
logger.error(f"Failed to list projects: {str(e)}", exc_info=True)
|
| 759 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
@app.post("/projects", response_model=Project, tags=["Projects"])
|
| 763 |
+
async def create_project(project: ProjectCreate):
|
| 764 |
+
"""Create a new project."""
|
| 765 |
+
try:
|
| 766 |
+
project_id = db.create_project(project.name, project.description)
|
| 767 |
+
created_project = db.get_project(project_id)
|
| 768 |
+
return created_project
|
| 769 |
+
except ValueError as e:
|
| 770 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 771 |
+
except Exception as e:
|
| 772 |
+
logger.error(f"Failed to create project: {str(e)}", exc_info=True)
|
| 773 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
@app.get("/projects/{project_id}", response_model=Project, tags=["Projects"])
|
| 777 |
+
async def get_project(project_id: int):
|
| 778 |
+
"""Get a specific project."""
|
| 779 |
+
try:
|
| 780 |
+
project = db.get_project(project_id)
|
| 781 |
+
if not project:
|
| 782 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 783 |
+
return project
|
| 784 |
+
except HTTPException:
|
| 785 |
+
raise
|
| 786 |
+
except Exception as e:
|
| 787 |
+
logger.error(f"Failed to get project: {str(e)}", exc_info=True)
|
| 788 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
@app.delete("/projects/{project_id}", tags=["Projects"])
|
| 792 |
+
async def delete_project(project_id: int):
|
| 793 |
+
"""Delete a project."""
|
| 794 |
+
try:
|
| 795 |
+
deleted = db.delete_project(project_id)
|
| 796 |
+
if not deleted:
|
| 797 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 798 |
+
return {"message": "Project deleted successfully"}
|
| 799 |
+
except HTTPException:
|
| 800 |
+
raise
|
| 801 |
+
except Exception as e:
|
| 802 |
+
logger.error(f"Failed to delete project: {str(e)}", exc_info=True)
|
| 803 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
@app.get("/projects/{project_id}/validations", tags=["Projects"])
|
| 807 |
+
async def get_project_validations(project_id: int):
|
| 808 |
+
"""Get all validations for a project."""
|
| 809 |
+
try:
|
| 810 |
+
validations = db.get_project_validations(project_id)
|
| 811 |
+
return validations
|
| 812 |
+
except Exception as e:
|
| 813 |
+
logger.error(f"Failed to get project validations: {str(e)}", exc_info=True)
|
| 814 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
@app.get("/validations/recent", tags=["Projects"])
|
| 818 |
+
async def get_recent_validations(limit: int = Query(50, ge=1, le=200)):
|
| 819 |
+
"""Get recent validations across all projects."""
|
| 820 |
+
try:
|
| 821 |
+
validations = db.get_recent_validations(limit)
|
| 822 |
+
return validations
|
| 823 |
+
except Exception as e:
|
| 824 |
+
logger.error(f"Failed to get recent validations: {str(e)}", exc_info=True)
|
| 825 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 826 |
+
|
| 827 |
+
|
| 828 |
if __name__ == "__main__":
|
| 829 |
import uvicorn
|
| 830 |
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
app/static/index.html
CHANGED
|
@@ -353,8 +353,6 @@
|
|
| 353 |
|
| 354 |
.spell-error-type {
|
| 355 |
display: inline-block;
|
| 356 |
-
background: #ffc107;
|
| 357 |
-
color: #856404;
|
| 358 |
padding: 3px 10px;
|
| 359 |
border-radius: 10px;
|
| 360 |
font-size: 12px;
|
|
@@ -362,27 +360,53 @@
|
|
| 362 |
margin-left: 10px;
|
| 363 |
}
|
| 364 |
|
| 365 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
display: flex;
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
padding: 12px;
|
| 370 |
-
background: #f8f9fa;
|
| 371 |
-
border-radius: 8px;
|
| 372 |
-
margin-bottom: 20px;
|
| 373 |
}
|
| 374 |
|
| 375 |
-
.
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
cursor: pointer;
|
|
|
|
|
|
|
| 379 |
}
|
| 380 |
|
| 381 |
-
.
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
|
| 388 |
.spell-check-no-errors {
|
|
@@ -393,6 +417,89 @@
|
|
| 393 |
text-align: center;
|
| 394 |
font-weight: 600;
|
| 395 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
</style>
|
| 397 |
</head>
|
| 398 |
|
|
@@ -401,10 +508,52 @@
|
|
| 401 |
<h1>Medical Document Validator</h1>
|
| 402 |
<p class="subtitle">Upload a document and select a template to validate against</p>
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
<form id="validationForm">
|
| 405 |
<div class="form-group">
|
| 406 |
-
<label for="templateSelect">Select Template:<
|
| 407 |
-
|
|
|
|
| 408 |
<option value="">Loading templates...</option>
|
| 409 |
</select>
|
| 410 |
</div>
|
|
@@ -415,13 +564,20 @@
|
|
| 415 |
<div class="file-info" id="fileInfo"></div>
|
| 416 |
</div>
|
| 417 |
|
| 418 |
-
<div class="
|
| 419 |
-
<
|
| 420 |
-
<
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
</div>
|
| 423 |
|
| 424 |
-
<
|
|
|
|
|
|
|
|
|
|
| 425 |
</form>
|
| 426 |
|
| 427 |
<div class="debug-section">
|
|
@@ -433,18 +589,105 @@
|
|
| 433 |
<div class="debug-info" id="debugInfo" style="display: none;"></div>
|
| 434 |
</div>
|
| 435 |
|
| 436 |
-
<
|
| 437 |
-
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
</div>
|
| 440 |
|
| 441 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
-
|
| 444 |
-
<div class="
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
</div>
|
| 449 |
|
| 450 |
<script>
|
|
@@ -481,17 +724,83 @@
|
|
| 481 |
}
|
| 482 |
});
|
| 483 |
|
| 484 |
-
// Handle
|
| 485 |
-
document.getElementById('
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
|
|
|
|
|
|
| 488 |
const templateKey = document.getElementById('templateSelect').value;
|
| 489 |
const fileInput = document.getElementById('fileInput');
|
| 490 |
const file = fileInput.files[0];
|
| 491 |
-
const spellCheckEnabled = document.getElementById('spellCheckToggle').checked;
|
| 492 |
|
| 493 |
if (!templateKey) {
|
| 494 |
-
showError('Please select a template');
|
| 495 |
return;
|
| 496 |
}
|
| 497 |
|
|
@@ -512,14 +821,21 @@
|
|
| 512 |
document.getElementById('results').style.display = 'none';
|
| 513 |
document.getElementById('error').style.display = 'none';
|
| 514 |
document.getElementById('loading').style.display = 'block';
|
| 515 |
-
document.getElementById('
|
|
|
|
| 516 |
|
| 517 |
try {
|
| 518 |
const formData = new FormData();
|
| 519 |
formData.append('file', file);
|
| 520 |
|
| 521 |
-
//
|
| 522 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
|
| 524 |
const response = await fetch(url, {
|
| 525 |
method: 'POST',
|
|
@@ -537,10 +853,65 @@
|
|
| 537 |
showError(error.message || 'An error occurred during validation');
|
| 538 |
} finally {
|
| 539 |
document.getElementById('loading').style.display = 'none';
|
| 540 |
-
document.getElementById('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
}
|
| 542 |
});
|
| 543 |
|
|
|
|
| 544 |
function showError(message) {
|
| 545 |
const errorDiv = document.getElementById('error');
|
| 546 |
errorDiv.textContent = message;
|
|
@@ -599,6 +970,11 @@
|
|
| 599 |
displaySpellCheck(data.spell_check);
|
| 600 |
}
|
| 601 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
resultsDiv.style.display = 'block';
|
| 603 |
document.getElementById('error').style.display = 'none';
|
| 604 |
}
|
|
@@ -612,13 +988,13 @@
|
|
| 612 |
|
| 613 |
const header = document.createElement('div');
|
| 614 |
header.className = 'spell-check-header';
|
| 615 |
-
header.innerHTML = `
|
| 616 |
spellSection.appendChild(header);
|
| 617 |
|
| 618 |
if (spellCheck.total_errors === 0) {
|
| 619 |
const noErrors = document.createElement('div');
|
| 620 |
noErrors.className = 'spell-check-no-errors';
|
| 621 |
-
noErrors.textContent = '✓ No spelling errors found!';
|
| 622 |
spellSection.appendChild(noErrors);
|
| 623 |
} else {
|
| 624 |
spellCheck.errors.forEach(error => {
|
|
@@ -627,7 +1003,7 @@
|
|
| 627 |
|
| 628 |
const wordDiv = document.createElement('div');
|
| 629 |
wordDiv.className = 'spell-error-word';
|
| 630 |
-
wordDiv.innerHTML = `"${error.word}"<span class="spell-error-type">${error.error_type}</span>`;
|
| 631 |
errorItem.appendChild(wordDiv);
|
| 632 |
|
| 633 |
if (error.context) {
|
|
@@ -664,6 +1040,93 @@
|
|
| 664 |
elementsList.appendChild(spellSection);
|
| 665 |
}
|
| 666 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
// Debug: Extract images
|
| 668 |
document.getElementById('debugBtn').addEventListener('click', async function () {
|
| 669 |
const templateKey = document.getElementById('templateSelect').value;
|
|
@@ -741,7 +1204,355 @@
|
|
| 741 |
}
|
| 742 |
});
|
| 743 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
// Load templates when page loads
|
|
|
|
| 745 |
loadTemplates();
|
| 746 |
</script>
|
| 747 |
</body>
|
|
|
|
| 353 |
|
| 354 |
.spell-error-type {
|
| 355 |
display: inline-block;
|
|
|
|
|
|
|
| 356 |
padding: 3px 10px;
|
| 357 |
border-radius: 10px;
|
| 358 |
font-size: 12px;
|
|
|
|
| 360 |
margin-left: 10px;
|
| 361 |
}
|
| 362 |
|
| 363 |
+
.type-spelling {
|
| 364 |
+
background: #ffc107;
|
| 365 |
+
color: #856404;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.type-grammar {
|
| 369 |
+
background: #007bff;
|
| 370 |
+
color: white;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.type-formatting {
|
| 374 |
+
background: #6f42c1;
|
| 375 |
+
color: white;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.button-group {
|
| 379 |
display: flex;
|
| 380 |
+
gap: 15px;
|
| 381 |
+
margin-top: 20px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
}
|
| 383 |
|
| 384 |
+
.btn-secondary {
|
| 385 |
+
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
| 386 |
+
color: white;
|
| 387 |
+
border: none;
|
| 388 |
+
padding: 14px 32px;
|
| 389 |
+
border-radius: 8px;
|
| 390 |
+
font-size: 16px;
|
| 391 |
+
font-weight: 600;
|
| 392 |
cursor: pointer;
|
| 393 |
+
flex: 1;
|
| 394 |
+
transition: transform 0.2s, box-shadow 0.2s;
|
| 395 |
}
|
| 396 |
|
| 397 |
+
.btn-secondary:hover {
|
| 398 |
+
transform: translateY(-2px);
|
| 399 |
+
box-shadow: 0 10px 20px rgba(108, 117, 125, 0.4);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.btn-secondary:active {
|
| 403 |
+
transform: translateY(0);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.btn-secondary:disabled {
|
| 407 |
+
background: #ccc;
|
| 408 |
+
cursor: not-allowed;
|
| 409 |
+
transform: none;
|
| 410 |
}
|
| 411 |
|
| 412 |
.spell-check-no-errors {
|
|
|
|
| 417 |
text-align: center;
|
| 418 |
font-weight: 600;
|
| 419 |
}
|
| 420 |
+
|
| 421 |
+
/* Link Validation Styles */
|
| 422 |
+
.link-validation-section {
|
| 423 |
+
margin-top: 20px;
|
| 424 |
+
padding: 20px;
|
| 425 |
+
background: #e8f4fd;
|
| 426 |
+
border-radius: 8px;
|
| 427 |
+
border-left: 4px solid #007bff;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.link-validation-header {
|
| 431 |
+
font-weight: 600;
|
| 432 |
+
font-size: 18px;
|
| 433 |
+
color: #333;
|
| 434 |
+
margin-bottom: 15px;
|
| 435 |
+
display: flex;
|
| 436 |
+
align-items: center;
|
| 437 |
+
gap: 10px;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
.link-list {
|
| 441 |
+
list-style: none;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.link-item {
|
| 445 |
+
background: white;
|
| 446 |
+
padding: 12px;
|
| 447 |
+
margin-bottom: 8px;
|
| 448 |
+
border-radius: 6px;
|
| 449 |
+
border: 1px solid #dee2e6;
|
| 450 |
+
display: flex;
|
| 451 |
+
justify-content: space-between;
|
| 452 |
+
align-items: center;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.link-item.broken {
|
| 456 |
+
border-left: 4px solid #dc3545;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.link-item.valid {
|
| 460 |
+
border-left: 4px solid #28a745;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.link-item.warning {
|
| 464 |
+
border-left: 4px solid #ffc107;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.link-url {
|
| 468 |
+
font-family: monospace;
|
| 469 |
+
color: #0056b3;
|
| 470 |
+
word-break: break-all;
|
| 471 |
+
margin-right: 10px;
|
| 472 |
+
font-size: 14px;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.link-meta {
|
| 476 |
+
color: #666;
|
| 477 |
+
font-size: 12px;
|
| 478 |
+
margin-top: 4px;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.link-status-badge {
|
| 482 |
+
padding: 4px 10px;
|
| 483 |
+
border-radius: 12px;
|
| 484 |
+
font-size: 12px;
|
| 485 |
+
font-weight: 600;
|
| 486 |
+
white-space: nowrap;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.status-valid {
|
| 490 |
+
background: #d4edda;
|
| 491 |
+
color: #155724;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.status-broken {
|
| 495 |
+
background: #f8d7da;
|
| 496 |
+
color: #721c24;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.status-warning {
|
| 500 |
+
background: #fff3cd;
|
| 501 |
+
color: #856404;
|
| 502 |
+
}
|
| 503 |
</style>
|
| 504 |
</head>
|
| 505 |
|
|
|
|
| 508 |
<h1>Medical Document Validator</h1>
|
| 509 |
<p class="subtitle">Upload a document and select a template to validate against</p>
|
| 510 |
|
| 511 |
+
<!-- Project Selector -->
|
| 512 |
+
<div
|
| 513 |
+
style="background: #e8f4f8; padding: 15px; border-radius: 8px; margin-bottom: 30px; border-left: 4px solid #007bff;">
|
| 514 |
+
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
| 515 |
+
<label style="font-weight: 600; margin: 0;">📂 Current Project:</label>
|
| 516 |
+
<select id="currentProject"
|
| 517 |
+
style="flex: 1; min-width: 200px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
|
| 518 |
+
<option value="">No Project (Not Saved)</option>
|
| 519 |
+
</select>
|
| 520 |
+
<button type="button" class="btn-secondary" id="createProjectBtn" style="white-space: nowrap;">
|
| 521 |
+
+ New Project
|
| 522 |
+
</button>
|
| 523 |
+
<button type="button" class="btn-secondary" id="viewProjectsBtn" style="white-space: nowrap;">
|
| 524 |
+
📋 View All
|
| 525 |
+
</button>
|
| 526 |
+
</div>
|
| 527 |
+
</div>
|
| 528 |
+
|
| 529 |
+
<!-- Create Project Modal -->
|
| 530 |
+
<div id="createProjectModal"
|
| 531 |
+
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
|
| 532 |
+
<div
|
| 533 |
+
style="background: white; padding: 30px; border-radius: 8px; max-width: 500px; width: 90%; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
| 534 |
+
<h2 style="margin-top: 0;">Create New Project</h2>
|
| 535 |
+
<div class="form-group">
|
| 536 |
+
<label for="projectName">Project Name: *</label>
|
| 537 |
+
<input type="text" id="projectName" placeholder="e.g., Cardiology Conference Q1 2025"
|
| 538 |
+
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
|
| 539 |
+
</div>
|
| 540 |
+
<div class="form-group">
|
| 541 |
+
<label for="projectDescription">Description (Optional):</label>
|
| 542 |
+
<textarea id="projectDescription" rows="3" placeholder="Brief description of this project..."
|
| 543 |
+
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; resize: vertical;"></textarea>
|
| 544 |
+
</div>
|
| 545 |
+
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
| 546 |
+
<button type="button" class="btn-secondary" id="cancelProjectBtn">Cancel</button>
|
| 547 |
+
<button type="button" class="btn" id="saveProjectBtn">Create Project</button>
|
| 548 |
+
</div>
|
| 549 |
+
</div>
|
| 550 |
+
</div>
|
| 551 |
+
|
| 552 |
<form id="validationForm">
|
| 553 |
<div class="form-group">
|
| 554 |
+
<label for="templateSelect">Select Template: <span style="color: #999; font-weight: 400;">(Optional for
|
| 555 |
+
spelling-only check)</span></label>
|
| 556 |
+
<select id="templateSelect" name="template">
|
| 557 |
<option value="">Loading templates...</option>
|
| 558 |
</select>
|
| 559 |
</div>
|
|
|
|
| 564 |
<div class="file-info" id="fileInfo"></div>
|
| 565 |
</div>
|
| 566 |
|
| 567 |
+
<div class="form-group">
|
| 568 |
+
<label for="customPrompt">Custom Instructions (Optional):</label>
|
| 569 |
+
<textarea id="customPrompt" name="customPrompt" rows="3" maxlength="500"
|
| 570 |
+
placeholder="Enter any additional instructions to customize the validation (e.g., 'Focus on date format validation' or 'Pay special attention to logo placement')..."
|
| 571 |
+
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-family: inherit; font-size: 14px; resize: vertical;"></textarea>
|
| 572 |
+
<div style="text-align: right; font-size: 12px; color: #666; margin-top: 4px;">
|
| 573 |
+
<span id="charCount">0</span>/500 characters
|
| 574 |
+
</div>
|
| 575 |
</div>
|
| 576 |
|
| 577 |
+
<div class="button-group">
|
| 578 |
+
<button type="button" class="btn" id="validateBtn">📋 Validate Document</button>
|
| 579 |
+
<button type="button" class="btn-secondary" id="spellingOnlyBtn">✨ Check Quality Only</button>
|
| 580 |
+
</div>
|
| 581 |
</form>
|
| 582 |
|
| 583 |
<div class="debug-section">
|
|
|
|
| 589 |
<div class="debug-info" id="debugInfo" style="display: none;"></div>
|
| 590 |
</div>
|
| 591 |
|
| 592 |
+
<!-- Document Comparison Section -->
|
| 593 |
+
<div class="comparison-section"
|
| 594 |
+
style="background: #f8f9fa; padding: 25px; border-radius: 8px; margin-top: 30px;">
|
| 595 |
+
<h3 style="margin-bottom: 15px; font-size: 20px; color: #333;">🔄 Compare Documents</h3>
|
| 596 |
+
<p style="color: #666; font-size: 14px; margin-bottom: 20px;">
|
| 597 |
+
Upload two versions of a document to see what changed (e.g., before and after edits).
|
| 598 |
+
</p>
|
| 599 |
+
|
| 600 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
| 601 |
+
<div class="form-group">
|
| 602 |
+
<label for="compareFile1">📄 Original Document:</label>
|
| 603 |
+
<input type="file" id="compareFile1" accept=".pdf,.docx,.pptx">
|
| 604 |
+
<div class="file-info" id="compareFileInfo1"></div>
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
<div class="form-group">
|
| 608 |
+
<label for="compareFile2">📝 Modified Document:</label>
|
| 609 |
+
<input type="file" id="compareFile2" accept=".pdf,.docx,.pptx">
|
| 610 |
+
<div class="file-info" id="compareFileInfo2"></div>
|
| 611 |
+
</div>
|
| 612 |
+
</div>
|
| 613 |
+
|
| 614 |
+
<button type="button" class="btn" id="compareBtn" style="width: 100%;">
|
| 615 |
+
🔍 Compare Documents
|
| 616 |
+
</button>
|
| 617 |
</div>
|
| 618 |
|
| 619 |
+
<!-- Bulk Certificate Validation Section -->
|
| 620 |
+
<div class="bulk-validation-section"
|
| 621 |
+
style="background: #f0f8ff; padding: 25px; border-radius: 8px; margin-top: 30px;">
|
| 622 |
+
<h3 style="margin-bottom: 15px; font-size: 20px; color: #333;">📋 Bulk Certificate Validation</h3>
|
| 623 |
+
<p style="color: #666; font-size: 14px; margin-bottom: 20px;">
|
| 624 |
+
Upload an Excel list of names and multiple certificates to verify all attendees received their
|
| 625 |
+
certificates.
|
| 626 |
+
</p>
|
| 627 |
+
|
| 628 |
+
<!-- Step 1: Excel Upload -->
|
| 629 |
+
<div class="form-group" style="margin-bottom: 20px;">
|
| 630 |
+
<label for="excelFile">1️⃣ Upload Excel File with Names:</label>
|
| 631 |
+
<input type="file" id="excelFile" accept=".xlsx">
|
| 632 |
+
<div class="file-info" id="excelFileInfo"></div>
|
| 633 |
+
</div>
|
| 634 |
+
|
| 635 |
+
<!-- Step 2: Column Selection -->
|
| 636 |
+
<div class="form-group" style="margin-bottom: 20px; display: none;" id="columnSelectorGroup">
|
| 637 |
+
<label for="nameColumn">2️⃣ Select Column Containing Names:</label>
|
| 638 |
+
<select id="nameColumn" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
|
| 639 |
+
<option value="">Loading columns...</option>
|
| 640 |
+
</select>
|
| 641 |
+
<div style="margin-top: 8px; color: #666; font-size: 13px;">
|
| 642 |
+
Preview: <span id="namePreview" style="font-weight: 500;"></span>
|
| 643 |
+
</div>
|
| 644 |
+
</div>
|
| 645 |
|
| 646 |
+
<!-- Step 3: Certificates Upload -->
|
| 647 |
+
<div class="form-group" style="margin-bottom: 20px;">
|
| 648 |
+
<label for="certificateFiles">3️⃣ Upload Certificates (Max 150):</label>
|
| 649 |
+
<input type="file" id="certificateFiles" multiple accept=".pdf,.pptx"">
|
| 650 |
+
<div style=" margin-top: 8px;">
|
| 651 |
+
<span style="font-weight: 600; color: #007bff;" id="certCount">0</span>
|
| 652 |
+
<span style="color: #666;">/150 files selected</span>
|
| 653 |
+
</div>
|
| 654 |
</div>
|
| 655 |
+
|
| 656 |
+
<!-- Step 4: Validate Button -->
|
| 657 |
+
<button type="button" class="btn" id="bulkValidateBtn" style="width: 100%;" disabled>
|
| 658 |
+
✅ Validate All Certificates
|
| 659 |
+
</button>
|
| 660 |
+
</div>
|
| 661 |
+
|
| 662 |
+
<div class="loading" id="loading">
|
| 663 |
+
<div class="spinner"></div>
|
| 664 |
+
<p>Validating document...</p>
|
| 665 |
+
</div>
|
| 666 |
+
|
| 667 |
+
<div class="error" id="error"></div>
|
| 668 |
+
|
| 669 |
+
<div class="results" id="results">
|
| 670 |
+
<div class="status" id="status"></div>
|
| 671 |
+
<div class="summary" id="summary"></div>
|
| 672 |
+
<ul class="elements-list" id="elementsList"></ul>
|
| 673 |
+
</div>
|
| 674 |
+
|
| 675 |
+
<!-- Comparison Results -->
|
| 676 |
+
<div class="results" id="comparisonResults" style="display: none;">
|
| 677 |
+
<h2 style="margin-bottom: 20px;">📊 Comparison Results</h2>
|
| 678 |
+
<div id="comparisonSummary" style="margin-bottom: 20px;"></div>
|
| 679 |
+
<div id="comparisonDetails"></div>
|
| 680 |
+
</div>
|
| 681 |
+
|
| 682 |
+
<!-- Bulk Validation Results -->
|
| 683 |
+
<div class="results" id="bulkResults" style="display: none;">
|
| 684 |
+
<h2 style="margin-bottom: 20px;">📊 Bulk Validation Results</h2>
|
| 685 |
+
<div id="bulkSummary" style="margin-bottom: 20px;"></div>
|
| 686 |
+
<button type="button" class="btn-secondary" id="downloadCSVBtn" style="margin-bottom: 20px;">
|
| 687 |
+
📥 Download CSV Report
|
| 688 |
+
</button>
|
| 689 |
+
<div id="bulkDetails"></div>
|
| 690 |
+
</div>
|
| 691 |
</div>
|
| 692 |
|
| 693 |
<script>
|
|
|
|
| 724 |
}
|
| 725 |
});
|
| 726 |
|
| 727 |
+
// Handle character count for custom prompt
|
| 728 |
+
document.getElementById('customPrompt').addEventListener('input', function () {
|
| 729 |
+
const count = this.value.length;
|
| 730 |
+
document.getElementById('charCount').textContent = count;
|
| 731 |
+
});
|
| 732 |
+
|
| 733 |
+
// Handle comparison file 1 input
|
| 734 |
+
document.getElementById('compareFile1').addEventListener('change', function (e) {
|
| 735 |
+
const file = e.target.files[0];
|
| 736 |
+
const fileInfo = document.getElementById('compareFileInfo1');
|
| 737 |
+
if (file) {
|
| 738 |
+
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
|
| 739 |
+
} else {
|
| 740 |
+
fileInfo.textContent = '';
|
| 741 |
+
}
|
| 742 |
+
});
|
| 743 |
+
|
| 744 |
+
// Handle comparison file 2 input
|
| 745 |
+
document.getElementById('compareFile2').addEventListener('change', function (e) {
|
| 746 |
+
const file = e.target.files[0];
|
| 747 |
+
const fileInfo = document.getElementById('compareFileInfo2');
|
| 748 |
+
if (file) {
|
| 749 |
+
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
|
| 750 |
+
} else {
|
| 751 |
+
fileInfo.textContent = '';
|
| 752 |
+
}
|
| 753 |
+
});
|
| 754 |
+
|
| 755 |
+
// Handle Compare Documents button
|
| 756 |
+
document.getElementById('compareBtn').addEventListener('click', async function () {
|
| 757 |
+
const file1 = document.getElementById('compareFile1').files[0];
|
| 758 |
+
const file2 = document.getElementById('compareFile2').files[0];
|
| 759 |
+
|
| 760 |
+
if (!file1 || !file2) {
|
| 761 |
+
showError('Please select both documents to compare');
|
| 762 |
+
return;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
// Hide previous results
|
| 766 |
+
document.getElementById('results').style.display = 'none';
|
| 767 |
+
document.getElementById('comparisonResults').style.display = 'none';
|
| 768 |
+
document.getElementById('error').style.display = 'none';
|
| 769 |
+
document.getElementById('loading').querySelector('p').textContent = 'Comparing documents...';
|
| 770 |
+
document.getElementById('loading').style.display = 'block';
|
| 771 |
+
|
| 772 |
+
try {
|
| 773 |
+
const formData = new FormData();
|
| 774 |
+
formData.append('file1', file1);
|
| 775 |
+
formData.append('file2', file2);
|
| 776 |
+
|
| 777 |
+
const response = await fetch('/compare', {
|
| 778 |
+
method: 'POST',
|
| 779 |
+
body: formData
|
| 780 |
+
});
|
| 781 |
+
|
| 782 |
+
const data = await response.json();
|
| 783 |
+
|
| 784 |
+
if (!response.ok) {
|
| 785 |
+
throw new Error(data.detail || 'Comparison failed');
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
displayComparisonResults(data);
|
| 789 |
+
} catch (error) {
|
| 790 |
+
showError(error.message || 'An error occurred during comparison');
|
| 791 |
+
} finally {
|
| 792 |
+
document.getElementById('loading').style.display = 'none';
|
| 793 |
+
}
|
| 794 |
+
});
|
| 795 |
|
| 796 |
+
// Handle Validate Document button (Template + Spelling)
|
| 797 |
+
document.getElementById('validateBtn').addEventListener('click', async function () {
|
| 798 |
const templateKey = document.getElementById('templateSelect').value;
|
| 799 |
const fileInput = document.getElementById('fileInput');
|
| 800 |
const file = fileInput.files[0];
|
|
|
|
| 801 |
|
| 802 |
if (!templateKey) {
|
| 803 |
+
showError('Please select a template for validation');
|
| 804 |
return;
|
| 805 |
}
|
| 806 |
|
|
|
|
| 821 |
document.getElementById('results').style.display = 'none';
|
| 822 |
document.getElementById('error').style.display = 'none';
|
| 823 |
document.getElementById('loading').style.display = 'block';
|
| 824 |
+
document.getElementById('validateBtn').disabled = true;
|
| 825 |
+
document.getElementById('spellingOnlyBtn').disabled = true;
|
| 826 |
|
| 827 |
try {
|
| 828 |
const formData = new FormData();
|
| 829 |
formData.append('file', file);
|
| 830 |
|
| 831 |
+
// Get custom prompt if provided
|
| 832 |
+
const customPrompt = document.getElementById('customPrompt').value.trim();
|
| 833 |
+
|
| 834 |
+
// Build URL - always include spell checking for validate mode
|
| 835 |
+
let url = `/validate?template_key=${encodeURIComponent(templateKey)}&check_spelling=true`;
|
| 836 |
+
if (customPrompt) {
|
| 837 |
+
url += `&custom_prompt=${encodeURIComponent(customPrompt)}`;
|
| 838 |
+
}
|
| 839 |
|
| 840 |
const response = await fetch(url, {
|
| 841 |
method: 'POST',
|
|
|
|
| 853 |
showError(error.message || 'An error occurred during validation');
|
| 854 |
} finally {
|
| 855 |
document.getElementById('loading').style.display = 'none';
|
| 856 |
+
document.getElementById('validateBtn').disabled = false;
|
| 857 |
+
document.getElementById('spellingOnlyBtn').disabled = false;
|
| 858 |
+
}
|
| 859 |
+
});
|
| 860 |
+
|
| 861 |
+
// Handle Check Spelling Only button
|
| 862 |
+
document.getElementById('spellingOnlyBtn').addEventListener('click', async function () {
|
| 863 |
+
const fileInput = document.getElementById('fileInput');
|
| 864 |
+
const file = fileInput.files[0];
|
| 865 |
+
|
| 866 |
+
if (!file) {
|
| 867 |
+
showError('Please select a file to upload');
|
| 868 |
+
return;
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
// Validate file type
|
| 872 |
+
const validExtensions = ['.pdf', '.docx', '.pptx'];
|
| 873 |
+
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
| 874 |
+
if (!validExtensions.includes(fileExtension)) {
|
| 875 |
+
showError('Invalid file type. Please upload a PDF, DOCX, or PPTX file.');
|
| 876 |
+
return;
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
// Hide previous results and errors
|
| 880 |
+
document.getElementById('results').style.display = 'none';
|
| 881 |
+
document.getElementById('error').style.display = 'none';
|
| 882 |
+
document.getElementById('loading').style.display = 'block';
|
| 883 |
+
document.getElementById('validateBtn').disabled = true;
|
| 884 |
+
document.getElementById('spellingOnlyBtn').disabled = true;
|
| 885 |
+
|
| 886 |
+
try {
|
| 887 |
+
const formData = new FormData();
|
| 888 |
+
formData.append('file', file);
|
| 889 |
+
|
| 890 |
+
// Spelling-only mode endpoint
|
| 891 |
+
const url = `/validate/spelling-only`;
|
| 892 |
+
|
| 893 |
+
const response = await fetch(url, {
|
| 894 |
+
method: 'POST',
|
| 895 |
+
body: formData
|
| 896 |
+
});
|
| 897 |
+
|
| 898 |
+
const data = await response.json();
|
| 899 |
+
|
| 900 |
+
if (!response.ok) {
|
| 901 |
+
throw new Error(data.detail || 'Spell check failed');
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
displaySpellingOnlyResults(data);
|
| 905 |
+
} catch (error) {
|
| 906 |
+
showError(error.message || 'An error occurred during spell checking');
|
| 907 |
+
} finally {
|
| 908 |
+
document.getElementById('loading').style.display = 'none';
|
| 909 |
+
document.getElementById('validateBtn').disabled = false;
|
| 910 |
+
document.getElementById('spellingOnlyBtn').disabled = false;
|
| 911 |
}
|
| 912 |
});
|
| 913 |
|
| 914 |
+
|
| 915 |
function showError(message) {
|
| 916 |
const errorDiv = document.getElementById('error');
|
| 917 |
errorDiv.textContent = message;
|
|
|
|
| 970 |
displaySpellCheck(data.spell_check);
|
| 971 |
}
|
| 972 |
|
| 973 |
+
// Display link validation results if available
|
| 974 |
+
if (data.link_report) {
|
| 975 |
+
displayLinkReport(data.link_report);
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
resultsDiv.style.display = 'block';
|
| 979 |
document.getElementById('error').style.display = 'none';
|
| 980 |
}
|
|
|
|
| 988 |
|
| 989 |
const header = document.createElement('div');
|
| 990 |
header.className = 'spell-check-header';
|
| 991 |
+
header.innerHTML = `✨ Quality & Spelling Check<span style="font-size: 14px; color: #666; font-weight: normal; margin-left: auto;">${spellCheck.summary}</span>`;
|
| 992 |
spellSection.appendChild(header);
|
| 993 |
|
| 994 |
if (spellCheck.total_errors === 0) {
|
| 995 |
const noErrors = document.createElement('div');
|
| 996 |
noErrors.className = 'spell-check-no-errors';
|
| 997 |
+
noErrors.textContent = '✓ No quality or spelling errors found!';
|
| 998 |
spellSection.appendChild(noErrors);
|
| 999 |
} else {
|
| 1000 |
spellCheck.errors.forEach(error => {
|
|
|
|
| 1003 |
|
| 1004 |
const wordDiv = document.createElement('div');
|
| 1005 |
wordDiv.className = 'spell-error-word';
|
| 1006 |
+
wordDiv.innerHTML = `"${error.word}"<span class="spell-error-type type-${error.error_type}">${error.error_type}</span>`;
|
| 1007 |
errorItem.appendChild(wordDiv);
|
| 1008 |
|
| 1009 |
if (error.context) {
|
|
|
|
| 1040 |
elementsList.appendChild(spellSection);
|
| 1041 |
}
|
| 1042 |
|
| 1043 |
+
function displayLinkReport(linkReport) {
|
| 1044 |
+
const elementsList = document.getElementById('elementsList');
|
| 1045 |
+
|
| 1046 |
+
// Create link results section
|
| 1047 |
+
const linkSection = document.createElement('div');
|
| 1048 |
+
linkSection.className = 'link-validation-section';
|
| 1049 |
+
|
| 1050 |
+
const header = document.createElement('div');
|
| 1051 |
+
header.className = 'link-validation-header';
|
| 1052 |
+
header.innerHTML = `🔗 Link Validation <span style="font-size: 14px; color: #666; font-weight: normal; margin-left: auto;">${linkReport.length} link(s) checked</span>`;
|
| 1053 |
+
linkSection.appendChild(header);
|
| 1054 |
+
|
| 1055 |
+
if (linkReport.length === 0) {
|
| 1056 |
+
const noLinks = document.createElement('div');
|
| 1057 |
+
noLinks.style.padding = '10px';
|
| 1058 |
+
noLinks.style.color = '#666';
|
| 1059 |
+
noLinks.style.fontStyle = 'italic';
|
| 1060 |
+
noLinks.textContent = 'No links found in document.';
|
| 1061 |
+
linkSection.appendChild(noLinks);
|
| 1062 |
+
} else {
|
| 1063 |
+
const list = document.createElement('ul');
|
| 1064 |
+
list.className = 'link-list';
|
| 1065 |
+
|
| 1066 |
+
linkReport.forEach(link => {
|
| 1067 |
+
const item = document.createElement('li');
|
| 1068 |
+
let statusClass = 'valid';
|
| 1069 |
+
if (link.status === 'broken') statusClass = 'broken';
|
| 1070 |
+
if (link.status === 'warning') statusClass = 'warning';
|
| 1071 |
+
|
| 1072 |
+
item.className = `link-item ${statusClass}`;
|
| 1073 |
+
|
| 1074 |
+
const leftDiv = document.createElement('div');
|
| 1075 |
+
leftDiv.style.flex = '1';
|
| 1076 |
+
leftDiv.style.marginRight = '10px';
|
| 1077 |
+
leftDiv.style.overflow = 'hidden';
|
| 1078 |
+
|
| 1079 |
+
const urlDiv = document.createElement('div');
|
| 1080 |
+
urlDiv.className = 'link-url';
|
| 1081 |
+
urlDiv.textContent = link.url;
|
| 1082 |
+
leftDiv.appendChild(urlDiv);
|
| 1083 |
+
|
| 1084 |
+
const metaDiv = document.createElement('div');
|
| 1085 |
+
metaDiv.className = 'link-meta';
|
| 1086 |
+
const pageInfo = link.page !== 'Unknown' ? `Page: ${link.page}` : '';
|
| 1087 |
+
metaDiv.textContent = `${pageInfo} ${link.message ? '• ' + link.message : ''}`;
|
| 1088 |
+
leftDiv.appendChild(metaDiv);
|
| 1089 |
+
|
| 1090 |
+
const badge = document.createElement('span');
|
| 1091 |
+
badge.className = `link-status-badge status-${statusClass}`;
|
| 1092 |
+
badge.textContent = link.status.toUpperCase();
|
| 1093 |
+
|
| 1094 |
+
item.appendChild(leftDiv);
|
| 1095 |
+
item.appendChild(badge);
|
| 1096 |
+
list.appendChild(item);
|
| 1097 |
+
});
|
| 1098 |
+
linkSection.appendChild(list);
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
elementsList.appendChild(linkSection);
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
function displaySpellingOnlyResults(data) {
|
| 1105 |
+
const resultsDiv = document.getElementById('results');
|
| 1106 |
+
const statusDiv = document.getElementById('status');
|
| 1107 |
+
const summaryDiv = document.getElementById('summary');
|
| 1108 |
+
const elementsList = document.getElementById('elementsList');
|
| 1109 |
+
|
| 1110 |
+
// Set status for spelling-only mode
|
| 1111 |
+
const hasErrors = data.spell_check && data.spell_check.total_errors > 0;
|
| 1112 |
+
statusDiv.textContent = hasErrors ? '⚠️ Issues Found' : '✓ Text Quality OK';
|
| 1113 |
+
statusDiv.className = `status ${hasErrors ? 'fail' : 'pass'}`;
|
| 1114 |
+
|
| 1115 |
+
// Set summary
|
| 1116 |
+
summaryDiv.textContent = data.spell_check ? data.spell_check.summary : 'Spell check completed';
|
| 1117 |
+
|
| 1118 |
+
// Clear elements list
|
| 1119 |
+
elementsList.innerHTML = '';
|
| 1120 |
+
|
| 1121 |
+
// Display spell check results
|
| 1122 |
+
if (data.spell_check) {
|
| 1123 |
+
displaySpellCheck(data.spell_check);
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
resultsDiv.style.display = 'block';
|
| 1127 |
+
document.getElementById('error').style.display = 'none';
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
// Debug: Extract images
|
| 1131 |
document.getElementById('debugBtn').addEventListener('click', async function () {
|
| 1132 |
const templateKey = document.getElementById('templateSelect').value;
|
|
|
|
| 1204 |
}
|
| 1205 |
});
|
| 1206 |
|
| 1207 |
+
// Function to display comparison results
|
| 1208 |
+
function displayComparisonResults(data) {
|
| 1209 |
+
const resultsDiv = document.getElementById('comparisonResults');
|
| 1210 |
+
const summaryDiv = document.getElementById('comparisonSummary');
|
| 1211 |
+
const detailsDiv = document.getElementById('comparisonDetails');
|
| 1212 |
+
|
| 1213 |
+
// Display summary
|
| 1214 |
+
summaryDiv.innerHTML = `
|
| 1215 |
+
<div class="summary" style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
|
| 1216 |
+
<h3 style="margin-bottom: 15px;">📝 Summary</h3>
|
| 1217 |
+
<div style="white-space: pre-wrap; line-height: 1.6;">${data.summary || 'No summary available'}</div>
|
| 1218 |
+
</div>
|
| 1219 |
+
`;
|
| 1220 |
+
|
| 1221 |
+
// Display detailed changes
|
| 1222 |
+
if (data.changes && data.changes.length > 0) {
|
| 1223 |
+
let changesHTML = '<h3 style="margin: 20px 0 15px 0;">🔍 Detailed Changes</h3><ul class="elements-list">';
|
| 1224 |
+
|
| 1225 |
+
data.changes.forEach(change => {
|
| 1226 |
+
const typeClass = change.type === 'addition' ? 'status-pass' :
|
| 1227 |
+
change.type === 'deletion' ? 'status-fail' : 'status-warning';
|
| 1228 |
+
const typeIcon = change.type === 'addition' ? '➕' :
|
| 1229 |
+
change.type === 'deletion' ? '➖' : '🔄';
|
| 1230 |
+
|
| 1231 |
+
changesHTML += `
|
| 1232 |
+
<li style="margin-bottom: 15px; padding: 15px; background: white; border-left: 4px solid ${change.type === 'addition' ? '#28a745' : change.type === 'deletion' ? '#dc3545' : '#ffc107'}; border-radius: 4px;">
|
| 1233 |
+
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
| 1234 |
+
<span class="status-badge ${typeClass}" style="margin-right: 10px;">${typeIcon} ${change.type.toUpperCase()}</span>
|
| 1235 |
+
${change.section ? `<strong>${change.section}</strong>` : ''}
|
| 1236 |
+
</div>
|
| 1237 |
+
<div style="color: #666; white-space: pre-wrap;">${change.description}</div>
|
| 1238 |
+
</li>
|
| 1239 |
+
`;
|
| 1240 |
+
});
|
| 1241 |
+
|
| 1242 |
+
changesHTML += '</ul>';
|
| 1243 |
+
detailsDiv.innerHTML = changesHTML;
|
| 1244 |
+
} else {
|
| 1245 |
+
detailsDiv.innerHTML = '<p style="color: #666; text-align: center; padding: 20px;">✅ No significant changes detected between the documents.</p>';
|
| 1246 |
+
}
|
| 1247 |
+
|
| 1248 |
+
resultsDiv.style.display = 'block';
|
| 1249 |
+
resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
// Bulk Validation: Excel file handler
|
| 1253 |
+
let excelFileData = null;
|
| 1254 |
+
let excelColumns = [];
|
| 1255 |
+
|
| 1256 |
+
document.getElementById('excelFile').addEventListener('change', async function (e) {
|
| 1257 |
+
const file = e.target.files[0];
|
| 1258 |
+
const fileInfo = document.getElementById('excelFileInfo');
|
| 1259 |
+
|
| 1260 |
+
if (file) {
|
| 1261 |
+
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
|
| 1262 |
+
|
| 1263 |
+
// Read and parse Excel to get columns
|
| 1264 |
+
try {
|
| 1265 |
+
excelFileData = file;
|
| 1266 |
+
const formData = new FormData();
|
| 1267 |
+
formData.append('file', file);
|
| 1268 |
+
|
| 1269 |
+
const response = await fetch('/excel-columns', {
|
| 1270 |
+
method: 'POST',
|
| 1271 |
+
body: formData
|
| 1272 |
+
});
|
| 1273 |
+
|
| 1274 |
+
const data = await response.json();
|
| 1275 |
+
|
| 1276 |
+
if (response.ok) {
|
| 1277 |
+
excelColumns = data.columns;
|
| 1278 |
+
const nameColumnSelect = document.getElementById('nameColumn');
|
| 1279 |
+
nameColumnSelect.innerHTML = '<option value="">-- Select Column --</option>';
|
| 1280 |
+
|
| 1281 |
+
excelColumns.forEach(col => {
|
| 1282 |
+
const option = document.createElement('option');
|
| 1283 |
+
option.value = col;
|
| 1284 |
+
option.textContent = col;
|
| 1285 |
+
nameColumnSelect.appendChild(option);
|
| 1286 |
+
});
|
| 1287 |
+
|
| 1288 |
+
document.getElementById('columnSelectorGroup').style.display = 'block';
|
| 1289 |
+
document.getElementById('namePreview').textContent = `${data.row_count} names found`;
|
| 1290 |
+
}
|
| 1291 |
+
} catch (error) {
|
| 1292 |
+
showError('Failed to parse Excel file: ' + error.message);
|
| 1293 |
+
}
|
| 1294 |
+
} else {
|
| 1295 |
+
fileInfo.textContent = '';
|
| 1296 |
+
document.getElementById('columnSelectorGroup').style.display = 'none';
|
| 1297 |
+
}
|
| 1298 |
+
});
|
| 1299 |
+
|
| 1300 |
+
// Bulk Validation: Certificate files handler
|
| 1301 |
+
document.getElementById('certificateFiles').addEventListener('change', function (e) {
|
| 1302 |
+
const files = e.target.files;
|
| 1303 |
+
const count = files.length;
|
| 1304 |
+
document.getElementById('certCount').textContent = count;
|
| 1305 |
+
|
| 1306 |
+
if (count > 150) {
|
| 1307 |
+
showError('Maximum 150 certificates allowed. Please reduce your selection.');
|
| 1308 |
+
this.value = '';
|
| 1309 |
+
document.getElementById('certCount').textContent = '0';
|
| 1310 |
+
return;
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
checkBulkValidateReady();
|
| 1314 |
+
});
|
| 1315 |
+
|
| 1316 |
+
// Bulk Validation: Column selection handler
|
| 1317 |
+
document.getElementById('nameColumn').addEventListener('change', function () {
|
| 1318 |
+
checkBulkValidateReady();
|
| 1319 |
+
});
|
| 1320 |
+
|
| 1321 |
+
// Check if bulk validate button should be enabled
|
| 1322 |
+
function checkBulkValidateReady() {
|
| 1323 |
+
const excelFile = document.getElementById('excelFile').files[0];
|
| 1324 |
+
const column = document.getElementById('nameColumn').value;
|
| 1325 |
+
const certFiles = document.getElementById('certificateFiles').files;
|
| 1326 |
+
|
| 1327 |
+
const btn = document.getElementById('bulkValidateBtn');
|
| 1328 |
+
btn.disabled = !(excelFile && column && certFiles.length > 0);
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
// Bulk Validation: Validate button handler
|
| 1332 |
+
document.getElementById('bulkValidateBtn').addEventListener('click', async function () {
|
| 1333 |
+
const excelFile = document.getElementById('excelFile').files[0];
|
| 1334 |
+
const nameColumn = document.getElementById('nameColumn').value;
|
| 1335 |
+
const certFiles = document.getElementById('certificateFiles').files;
|
| 1336 |
+
|
| 1337 |
+
if (!excelFile || !nameColumn || certFiles.length === 0) {
|
| 1338 |
+
showError('Please complete all steps before validating');
|
| 1339 |
+
return;
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
+
// Hide previous results
|
| 1343 |
+
document.getElementById('results').style.display = 'none';
|
| 1344 |
+
document.getElementById('comparisonResults').style.display = 'none';
|
| 1345 |
+
document.getElementById('bulkResults').style.display = 'none';
|
| 1346 |
+
document.getElementById('error').style.display = 'none';
|
| 1347 |
+
document.getElementById('loading').querySelector('p').textContent = `Processing ${certFiles.length} certificates...`;
|
| 1348 |
+
document.getElementById('loading').style.display = 'block';
|
| 1349 |
+
this.disabled = true;
|
| 1350 |
+
|
| 1351 |
+
try {
|
| 1352 |
+
const formData = new FormData();
|
| 1353 |
+
formData.append('excel_file', excelFile);
|
| 1354 |
+
formData.append('name_column', nameColumn);
|
| 1355 |
+
|
| 1356 |
+
for (let i = 0; i < certFiles.length; i++) {
|
| 1357 |
+
formData.append('certificate_files', certFiles[i]);
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
const response = await fetch('/bulk-validate', {
|
| 1361 |
+
method: 'POST',
|
| 1362 |
+
body: formData
|
| 1363 |
+
});
|
| 1364 |
+
|
| 1365 |
+
const data = await response.json();
|
| 1366 |
+
|
| 1367 |
+
if (!response.ok) {
|
| 1368 |
+
throw new Error(data.detail || 'Bulk validation failed');
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
displayBulkResults(data);
|
| 1372 |
+
} catch (error) {
|
| 1373 |
+
showError(error.message || 'An error occurred during bulk validation');
|
| 1374 |
+
} finally {
|
| 1375 |
+
document.getElementById('loading').style.display = 'none';
|
| 1376 |
+
this.disabled = false;
|
| 1377 |
+
}
|
| 1378 |
+
});
|
| 1379 |
+
|
| 1380 |
+
// Display bulk validation results
|
| 1381 |
+
let bulkResultsData = null;
|
| 1382 |
+
|
| 1383 |
+
function displayBulkResults(data) {
|
| 1384 |
+
bulkResultsData = data;
|
| 1385 |
+
const resultsDiv = document.getElementById('bulkResults');
|
| 1386 |
+
const summaryDiv = document.getElementById('bulkSummary');
|
| 1387 |
+
const detailsDiv = document.getElementById('bulkDetails');
|
| 1388 |
+
|
| 1389 |
+
// Summary
|
| 1390 |
+
summaryDiv.innerHTML = `
|
| 1391 |
+
<div class="summary" style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
|
| 1392 |
+
<h3 style="margin-bottom: 15px;">📊 Summary</h3>
|
| 1393 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px;">
|
| 1394 |
+
<div style="text-align: center; padding: 15px; background: white; border-radius: 6px;">
|
| 1395 |
+
<div style="font-size: 32px; font-weight: bold; color: #007bff;">${data.total_names}</div>
|
| 1396 |
+
<div style="color: #666; font-size: 14px;">Total Names</div>
|
| 1397 |
+
</div>
|
| 1398 |
+
<div style="text-align: center; padding: 15px; background: white; border-radius: 6px;">
|
| 1399 |
+
<div style="font-size: 32px; font-weight: bold; color: #6c757d;">${data.total_certificates}</div>
|
| 1400 |
+
<div style="color: #666; font-size: 14px;">Certificates</div>
|
| 1401 |
+
</div>
|
| 1402 |
+
<div style="text-align: center; padding: 15px; background: white; border-radius: 6px;">
|
| 1403 |
+
<div style="font-size: 32px; font-weight: bold; color: #28a745;">${data.exact_matches}</div>
|
| 1404 |
+
<div style="color: #666; font-size: 14px;">✅ Exact</div>
|
| 1405 |
+
</div>
|
| 1406 |
+
<div style="text-align: center; padding: 15px; background: white; border-radius: 6px;">
|
| 1407 |
+
<div style="font-size: 32px; font-weight: bold; color: #ffc107;">${data.fuzzy_matches}</div>
|
| 1408 |
+
<div style="color: #666; font-size: 14px;">⚠️ Fuzzy</div>
|
| 1409 |
+
</div>
|
| 1410 |
+
<div style="text-align: center; padding: 15px; background: white; border-radius: 6px;">
|
| 1411 |
+
<div style="font-size: 32px; font-weight: bold; color: #dc3545;">${data.missing}</div>
|
| 1412 |
+
<div style="color: #666; font-size: 14px;">❌ Missing</div>
|
| 1413 |
+
</div>
|
| 1414 |
+
<div style="text-align: center; padding: 15px; background: white; border-radius: 6px;">
|
| 1415 |
+
<div style="font-size: 32px; font-weight: bold; color: #17a2b8;">${data.extras}</div>
|
| 1416 |
+
<div style="color: #666; font-size: 14px;">➕ Extra</div>
|
| 1417 |
+
</div>
|
| 1418 |
+
</div>
|
| 1419 |
+
</div>
|
| 1420 |
+
`;
|
| 1421 |
+
|
| 1422 |
+
// Details
|
| 1423 |
+
let detailsHTML = '<h3 style="margin: 20px 0 15px 0;">📋 Detailed Results</h3><ul class="elements-list">';
|
| 1424 |
+
|
| 1425 |
+
data.details.forEach(item => {
|
| 1426 |
+
const status = item.status;
|
| 1427 |
+
const bgColor = status === 'exact_match' ? '#d4edda' :
|
| 1428 |
+
status === 'fuzzy_match' ? '#fff3cd' :
|
| 1429 |
+
status === 'missing' ? '#f8d7da' : '#d1ecf1';
|
| 1430 |
+
const icon = status === 'exact_match' ? '✅' :
|
| 1431 |
+
status === 'fuzzy_match' ? '⚠️' :
|
| 1432 |
+
status === 'missing' ? '❌' : '➕';
|
| 1433 |
+
const label = status === 'exact_match' ? 'EXACT MATCH' :
|
| 1434 |
+
status === 'fuzzy_match' ? `FUZZY MATCH (${item.similarity}%)` :
|
| 1435 |
+
status === 'missing' ? 'MISSING' : 'EXTRA';
|
| 1436 |
+
|
| 1437 |
+
detailsHTML += `
|
| 1438 |
+
<li style="margin-bottom: 10px; padding: 12px; background: ${bgColor}; border-radius: 4px;">
|
| 1439 |
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 1440 |
+
<div>
|
| 1441 |
+
<strong>${item.name}</strong>
|
| 1442 |
+
${item.certificate_file ? `<div style="font-size: 12px; color: #666; margin-top: 4px;">📄 ${item.certificate_file}</div>` : ''}
|
| 1443 |
+
</div>
|
| 1444 |
+
<span style="font-size: 14px; font-weight: 600;">${icon} ${label}</span>
|
| 1445 |
+
</div>
|
| 1446 |
+
</li>
|
| 1447 |
+
`;
|
| 1448 |
+
});
|
| 1449 |
+
|
| 1450 |
+
detailsHTML += '</ul>';
|
| 1451 |
+
detailsDiv.innerHTML = detailsHTML;
|
| 1452 |
+
|
| 1453 |
+
resultsDiv.style.display = 'block';
|
| 1454 |
+
resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 1455 |
+
}
|
| 1456 |
+
|
| 1457 |
+
// CSV Download handler
|
| 1458 |
+
document.getElementById('downloadCSVBtn').addEventListener('click', function () {
|
| 1459 |
+
if (!bulkResultsData) return;
|
| 1460 |
+
|
| 1461 |
+
let csv = 'Name,Status,Certificate File,Match Type,Similarity\n';
|
| 1462 |
+
|
| 1463 |
+
bulkResultsData.details.forEach(item => {
|
| 1464 |
+
const status = item.status === 'exact_match' ? 'Found' :
|
| 1465 |
+
item.status === 'fuzzy_match' ? 'Found' :
|
| 1466 |
+
item.status === 'missing' ? 'Missing' : 'Extra';
|
| 1467 |
+
const matchType = item.status === 'exact_match' ? 'Exact' :
|
| 1468 |
+
item.status === 'fuzzy_match' ? 'Fuzzy' : '-';
|
| 1469 |
+
const similarity = item.similarity || '-';
|
| 1470 |
+
const certFile = item.certificate_file || '-';
|
| 1471 |
+
|
| 1472 |
+
csv += `"${item.name}","${status}","${certFile}","${matchType}","${similarity}"\n`;
|
| 1473 |
+
});
|
| 1474 |
+
|
| 1475 |
+
const blob = new Blob([csv], { type: 'text/csv' });
|
| 1476 |
+
const url = window.URL.createObjectURL(blob);
|
| 1477 |
+
const a = document.createElement('a');
|
| 1478 |
+
a.href = url;
|
| 1479 |
+
a.download = 'bulk_validation_results.csv';
|
| 1480 |
+
a.click();
|
| 1481 |
+
window.URL.revokeObjectURL(url);
|
| 1482 |
+
});
|
| 1483 |
+
|
| 1484 |
+
// ==================== PROJECTS FUNCTIONALITY ====================
|
| 1485 |
+
|
| 1486 |
+
// Load projects list
|
| 1487 |
+
async function loadProjects() {
|
| 1488 |
+
try {
|
| 1489 |
+
const response = await fetch('/projects');
|
| 1490 |
+
const projects = await response.json();
|
| 1491 |
+
|
| 1492 |
+
const selector = document.getElementById('currentProject');
|
| 1493 |
+
selector.innerHTML = '<option value="">No Project (Not Saved)</option>';
|
| 1494 |
+
|
| 1495 |
+
projects.forEach(project => {
|
| 1496 |
+
const option = document.createElement('option');
|
| 1497 |
+
option.value = project.id;
|
| 1498 |
+
option.textContent = `${project.name} (${project.validation_count} validations)`;
|
| 1499 |
+
selector.appendChild(option);
|
| 1500 |
+
});
|
| 1501 |
+
} catch (error) {
|
| 1502 |
+
console.error('Failed to load projects:', error);
|
| 1503 |
+
}
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
// Create project modal handlers
|
| 1507 |
+
document.getElementById('createProjectBtn').addEventListener('click', function () {
|
| 1508 |
+
document.getElementById('createProjectModal').style.display = 'flex';
|
| 1509 |
+
document.getElementById('projectName').value = '';
|
| 1510 |
+
document.getElementById('projectDescription').value = '';
|
| 1511 |
+
});
|
| 1512 |
+
|
| 1513 |
+
document.getElementById('cancelProjectBtn').addEventListener('click', function () {
|
| 1514 |
+
document.getElementById('createProjectModal').style.display = 'none';
|
| 1515 |
+
});
|
| 1516 |
+
|
| 1517 |
+
document.getElementById('saveProjectBtn').addEventListener('click', async function () {
|
| 1518 |
+
const name = document.getElementById('projectName').value.trim();
|
| 1519 |
+
const description = document.getElementById('projectDescription').value.trim();
|
| 1520 |
+
|
| 1521 |
+
if (!name) {
|
| 1522 |
+
showError('Project name is required');
|
| 1523 |
+
return;
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
try {
|
| 1527 |
+
const response = await fetch('/projects', {
|
| 1528 |
+
method: 'POST',
|
| 1529 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1530 |
+
body: JSON.stringify({ name, description })
|
| 1531 |
+
});
|
| 1532 |
+
|
| 1533 |
+
if (!response.ok) {
|
| 1534 |
+
const error = await response.json();
|
| 1535 |
+
throw new Error(error.detail || 'Failed to create project');
|
| 1536 |
+
}
|
| 1537 |
+
|
| 1538 |
+
const project = await response.json();
|
| 1539 |
+
document.getElementById('createProjectModal').style.display = 'none';
|
| 1540 |
+
await loadProjects();
|
| 1541 |
+
document.getElementById('currentProject').value = project.id;
|
| 1542 |
+
showError(''); // clear error
|
| 1543 |
+
} catch (error) {
|
| 1544 |
+
showError(error.message);
|
| 1545 |
+
}
|
| 1546 |
+
});
|
| 1547 |
+
|
| 1548 |
+
// View all projects
|
| 1549 |
+
document.getElementById('viewProjectsBtn').addEventListener('click', function () {
|
| 1550 |
+
// For now, just alert - can be enhanced later
|
| 1551 |
+
alert('Projects view coming soon! For now, use the dropdown to select projects.');
|
| 1552 |
+
});
|
| 1553 |
+
|
| 1554 |
// Load templates when page loads
|
| 1555 |
+
loadProjects();
|
| 1556 |
loadTemplates();
|
| 1557 |
</script>
|
| 1558 |
</body>
|
app/templates.json
CHANGED
|
@@ -4,292 +4,1765 @@
|
|
| 4 |
"template_key": "certificate_appreciation_speaker",
|
| 5 |
"friendly_name": "Certificate of Appreciation (Speaker/Chairperson)",
|
| 6 |
"elements": [
|
| 7 |
-
{
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
]
|
| 17 |
},
|
| 18 |
{
|
| 19 |
"template_key": "certificate_attendance",
|
| 20 |
"friendly_name": "Certificate of Attendance",
|
| 21 |
"elements": [
|
| 22 |
-
{
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
]
|
| 31 |
},
|
| 32 |
{
|
| 33 |
"template_key": "cpd_certificate_accreditation_generic",
|
| 34 |
"friendly_name": "CPD Certificate of Accreditation (Generic)",
|
| 35 |
"elements": [
|
| 36 |
-
{
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
]
|
| 45 |
},
|
| 46 |
{
|
| 47 |
"template_key": "html_email_reminder",
|
| 48 |
"friendly_name": "HTML Email Reminder",
|
| 49 |
"elements": [
|
| 50 |
-
{
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
{
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
{
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
]
|
| 65 |
},
|
| 66 |
{
|
| 67 |
"template_key": "html_invitation",
|
| 68 |
"friendly_name": "HTML Invitation",
|
| 69 |
"elements": [
|
| 70 |
-
{
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
{
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
{
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
]
|
| 86 |
},
|
| 87 |
{
|
| 88 |
"template_key": "pdf_invitation",
|
| 89 |
"friendly_name": "PDF Invitation",
|
| 90 |
"elements": [
|
| 91 |
-
{
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
{
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
{
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
]
|
| 107 |
},
|
| 108 |
{
|
| 109 |
"template_key": "pdf_save_the_date",
|
| 110 |
"friendly_name": "PDF Save the Date",
|
| 111 |
"elements": [
|
| 112 |
-
{
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
{
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
]
|
| 126 |
},
|
| 127 |
{
|
| 128 |
"template_key": "printed_invitation",
|
| 129 |
"friendly_name": "Printed Invitation",
|
| 130 |
"elements": [
|
| 131 |
-
{
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
{
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
]
|
| 148 |
},
|
| 149 |
{
|
| 150 |
"template_key": "rcp_certificate_attendance",
|
| 151 |
"friendly_name": "RCP Certificate of Attendance",
|
| 152 |
"elements": [
|
| 153 |
-
{
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
]
|
| 161 |
},
|
| 162 |
{
|
| 163 |
"template_key": "agenda",
|
| 164 |
"friendly_name": "Agenda Page",
|
| 165 |
"elements": [
|
| 166 |
-
{
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
{
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
]
|
| 177 |
},
|
| 178 |
{
|
| 179 |
"template_key": "certificate_accreditation_dha_full",
|
| 180 |
"friendly_name": "DHA Certificate of Accreditation (President + Chairs)",
|
| 181 |
"elements": [
|
| 182 |
-
{
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
{
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
]
|
| 194 |
},
|
| 195 |
{
|
| 196 |
"template_key": "certificate_appreciation_sponsor",
|
| 197 |
"friendly_name": "Certificate of Appreciation (Sponsor)",
|
| 198 |
"elements": [
|
| 199 |
-
{
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
]
|
| 209 |
},
|
| 210 |
{
|
| 211 |
"template_key": "evaluation_form_post_event",
|
| 212 |
"friendly_name": "Evaluation Form (Post-Event)",
|
| 213 |
"elements": [
|
| 214 |
-
{
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
]
|
| 223 |
},
|
| 224 |
{
|
| 225 |
"template_key": "event_booklet",
|
| 226 |
"friendly_name": "Event Booklet",
|
| 227 |
"elements": [
|
| 228 |
-
{
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
{
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
{
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
]
|
| 245 |
},
|
| 246 |
{
|
| 247 |
"template_key": "landing_page_registration",
|
| 248 |
"friendly_name": "Landing Page & Registration",
|
| 249 |
"elements": [
|
| 250 |
-
{
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
{
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
{
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
{
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
]
|
| 276 |
},
|
| 277 |
{
|
| 278 |
"template_key": "slides_permission",
|
| 279 |
"friendly_name": "Slides Permission Form",
|
| 280 |
"elements": [
|
| 281 |
-
{
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
{
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
]
|
| 292 |
}
|
| 293 |
]
|
| 294 |
-
}
|
| 295 |
-
|
|
|
|
| 4 |
"template_key": "certificate_appreciation_speaker",
|
| 5 |
"friendly_name": "Certificate of Appreciation (Speaker/Chairperson)",
|
| 6 |
"elements": [
|
| 7 |
+
{
|
| 8 |
+
"id": "certificate_title",
|
| 9 |
+
"label": "Certificate Title",
|
| 10 |
+
"type": "text",
|
| 11 |
+
"required": true,
|
| 12 |
+
"expected_phrases": [
|
| 13 |
+
"Certificate of Appreciation"
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "recipient_name",
|
| 18 |
+
"label": "Recipient Name",
|
| 19 |
+
"type": "person_name",
|
| 20 |
+
"required": true,
|
| 21 |
+
"placeholder_examples": [
|
| 22 |
+
"Dr << Name >>"
|
| 23 |
+
]
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"id": "purpose_text",
|
| 27 |
+
"label": "Purpose Text",
|
| 28 |
+
"type": "text",
|
| 29 |
+
"required": true,
|
| 30 |
+
"expected_phrases": [
|
| 31 |
+
"to express our gratitude and appreciation for participating as a Speaker/Chairperson"
|
| 32 |
+
]
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "event_name",
|
| 36 |
+
"label": "Event Name",
|
| 37 |
+
"type": "event_name",
|
| 38 |
+
"required": true,
|
| 39 |
+
"placeholder_examples": [
|
| 40 |
+
"<<Event Name>>"
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"id": "event_date",
|
| 45 |
+
"label": "Event Date",
|
| 46 |
+
"type": "date",
|
| 47 |
+
"required": true,
|
| 48 |
+
"placeholder_examples": [
|
| 49 |
+
"<<Event Date>>"
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": "venue",
|
| 54 |
+
"label": "Venue",
|
| 55 |
+
"type": "venue",
|
| 56 |
+
"required": true,
|
| 57 |
+
"placeholder_examples": [
|
| 58 |
+
"<<Venue>>"
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"id": "event_chairperson_signature",
|
| 63 |
+
"label": "Event Chairperson Signature",
|
| 64 |
+
"type": "signature_block",
|
| 65 |
+
"required": true,
|
| 66 |
+
"expected_phrases": [
|
| 67 |
+
"Event Chairperson",
|
| 68 |
+
"<<Signature>>"
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"id": "event_logo",
|
| 73 |
+
"label": "Event Logo",
|
| 74 |
+
"type": "logo",
|
| 75 |
+
"required": true,
|
| 76 |
+
"logo_role": "event"
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"id": "company_logo",
|
| 80 |
+
"label": "Company Logo",
|
| 81 |
+
"type": "logo",
|
| 82 |
+
"required": true,
|
| 83 |
+
"logo_role": "company"
|
| 84 |
+
}
|
| 85 |
]
|
| 86 |
},
|
| 87 |
{
|
| 88 |
"template_key": "certificate_attendance",
|
| 89 |
"friendly_name": "Certificate of Attendance",
|
| 90 |
"elements": [
|
| 91 |
+
{
|
| 92 |
+
"id": "certificate_title",
|
| 93 |
+
"label": "Certificate Title",
|
| 94 |
+
"type": "text",
|
| 95 |
+
"required": true,
|
| 96 |
+
"expected_phrases": [
|
| 97 |
+
"Certificate of Attendance"
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"id": "recipient_name",
|
| 102 |
+
"label": "Recipient Name",
|
| 103 |
+
"type": "person_name",
|
| 104 |
+
"required": true,
|
| 105 |
+
"placeholder_examples": [
|
| 106 |
+
"Dr. << Name >>"
|
| 107 |
+
]
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"id": "confirmation_text",
|
| 111 |
+
"label": "Confirmation Text",
|
| 112 |
+
"type": "text",
|
| 113 |
+
"required": true,
|
| 114 |
+
"expected_phrases": [
|
| 115 |
+
"This certificate confirms that",
|
| 116 |
+
"has attended the meeting titled"
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"id": "event_name",
|
| 121 |
+
"label": "Event Name",
|
| 122 |
+
"type": "event_name",
|
| 123 |
+
"required": true
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"id": "event_date",
|
| 127 |
+
"label": "Event Date",
|
| 128 |
+
"type": "date",
|
| 129 |
+
"required": true
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"id": "event_chairperson_signature",
|
| 133 |
+
"label": "Event Chairperson Signature",
|
| 134 |
+
"type": "signature_block",
|
| 135 |
+
"required": true
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"id": "event_logo",
|
| 139 |
+
"label": "Event Logo",
|
| 140 |
+
"type": "logo",
|
| 141 |
+
"required": true,
|
| 142 |
+
"logo_role": "event"
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"id": "company_logo",
|
| 146 |
+
"label": "Company Logo",
|
| 147 |
+
"type": "logo",
|
| 148 |
+
"required": true,
|
| 149 |
+
"logo_role": "company"
|
| 150 |
+
}
|
| 151 |
]
|
| 152 |
},
|
| 153 |
{
|
| 154 |
"template_key": "cpd_certificate_accreditation_generic",
|
| 155 |
"friendly_name": "CPD Certificate of Accreditation (Generic)",
|
| 156 |
"elements": [
|
| 157 |
+
{
|
| 158 |
+
"id": "certificate_title",
|
| 159 |
+
"label": "Certificate Title",
|
| 160 |
+
"type": "text",
|
| 161 |
+
"required": true,
|
| 162 |
+
"expected_phrases": [
|
| 163 |
+
"Certificate of Accreditation"
|
| 164 |
+
]
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"id": "recipient_name",
|
| 168 |
+
"label": "Recipient Full Name",
|
| 169 |
+
"type": "person_name",
|
| 170 |
+
"required": true,
|
| 171 |
+
"placeholder_examples": [
|
| 172 |
+
"<<Full Name>>"
|
| 173 |
+
]
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"id": "accreditation_code",
|
| 177 |
+
"label": "Accreditation Code Number",
|
| 178 |
+
"type": "code",
|
| 179 |
+
"required": true
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"id": "cpd_points",
|
| 183 |
+
"label": "CPD Credit Points",
|
| 184 |
+
"type": "number_or_text",
|
| 185 |
+
"required": true
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"id": "event_name",
|
| 189 |
+
"label": "Event Name",
|
| 190 |
+
"type": "event_name",
|
| 191 |
+
"required": true
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"id": "event_date",
|
| 195 |
+
"label": "Event Date",
|
| 196 |
+
"type": "date",
|
| 197 |
+
"required": true
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"id": "venue",
|
| 201 |
+
"label": "Venue",
|
| 202 |
+
"type": "venue",
|
| 203 |
+
"required": true
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"id": "confirmation_text",
|
| 207 |
+
"label": "Attendance Confirmation Text",
|
| 208 |
+
"type": "text",
|
| 209 |
+
"required": true,
|
| 210 |
+
"expected_phrases": [
|
| 211 |
+
"This certificate confirms that",
|
| 212 |
+
"Has attended the event"
|
| 213 |
+
]
|
| 214 |
+
}
|
| 215 |
]
|
| 216 |
},
|
| 217 |
{
|
| 218 |
"template_key": "html_email_reminder",
|
| 219 |
"friendly_name": "HTML Email Reminder",
|
| 220 |
"elements": [
|
| 221 |
+
{
|
| 222 |
+
"id": "company_logo",
|
| 223 |
+
"label": "Company Logo",
|
| 224 |
+
"type": "logo",
|
| 225 |
+
"required": true
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"id": "event_logo",
|
| 229 |
+
"label": "Event Logo",
|
| 230 |
+
"type": "logo",
|
| 231 |
+
"required": true
|
| 232 |
+
},
|
| 233 |
+
{
|
| 234 |
+
"id": "greeting",
|
| 235 |
+
"label": "Greeting with First Name Merge Tag",
|
| 236 |
+
"type": "text",
|
| 237 |
+
"required": true,
|
| 238 |
+
"expected_phrases": [
|
| 239 |
+
"Dear Dr.",
|
| 240 |
+
"*|FNAME|*"
|
| 241 |
+
]
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"id": "event_name",
|
| 245 |
+
"label": "Event Name",
|
| 246 |
+
"type": "event_name",
|
| 247 |
+
"required": true
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"id": "event_date",
|
| 251 |
+
"label": "Event Date",
|
| 252 |
+
"type": "date",
|
| 253 |
+
"required": true
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"id": "time",
|
| 257 |
+
"label": "Time Range",
|
| 258 |
+
"type": "time_range",
|
| 259 |
+
"required": true
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
"id": "venue",
|
| 263 |
+
"label": "Venue",
|
| 264 |
+
"type": "venue",
|
| 265 |
+
"required": true
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"id": "chairpersons_block",
|
| 269 |
+
"label": "Chairpersons Block",
|
| 270 |
+
"type": "section",
|
| 271 |
+
"required": true,
|
| 272 |
+
"expected_subfields": [
|
| 273 |
+
"Pic",
|
| 274 |
+
"Name",
|
| 275 |
+
"Bio"
|
| 276 |
+
]
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"id": "speakers_block",
|
| 280 |
+
"label": "Speakers Block",
|
| 281 |
+
"type": "section",
|
| 282 |
+
"required": true,
|
| 283 |
+
"expected_subfields": [
|
| 284 |
+
"Pic",
|
| 285 |
+
"Name",
|
| 286 |
+
"Bio"
|
| 287 |
+
]
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"id": "agenda_table",
|
| 291 |
+
"label": "Agenda Table",
|
| 292 |
+
"type": "table",
|
| 293 |
+
"required": true,
|
| 294 |
+
"expected_columns": [
|
| 295 |
+
"Time",
|
| 296 |
+
"Topic",
|
| 297 |
+
"Faculty"
|
| 298 |
+
]
|
| 299 |
+
},
|
| 300 |
+
{
|
| 301 |
+
"id": "cta_register_join",
|
| 302 |
+
"label": "CTA to Register/Join",
|
| 303 |
+
"type": "cta",
|
| 304 |
+
"required": true,
|
| 305 |
+
"expected_phrases": [
|
| 306 |
+
"Click here to register",
|
| 307 |
+
"Click here to join"
|
| 308 |
+
]
|
| 309 |
+
},
|
| 310 |
+
{
|
| 311 |
+
"id": "disclaimer",
|
| 312 |
+
"label": "Disclaimer",
|
| 313 |
+
"type": "text",
|
| 314 |
+
"required": true
|
| 315 |
+
},
|
| 316 |
+
{
|
| 317 |
+
"id": "preparation_date",
|
| 318 |
+
"label": "Preparation Date",
|
| 319 |
+
"type": "date_or_placeholder",
|
| 320 |
+
"required": true
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"id": "approval_code",
|
| 324 |
+
"label": "Approval Code",
|
| 325 |
+
"type": "code",
|
| 326 |
+
"required": true
|
| 327 |
+
}
|
| 328 |
]
|
| 329 |
},
|
| 330 |
{
|
| 331 |
"template_key": "html_invitation",
|
| 332 |
"friendly_name": "HTML Invitation",
|
| 333 |
"elements": [
|
| 334 |
+
{
|
| 335 |
+
"id": "company_logo",
|
| 336 |
+
"label": "Company Logo",
|
| 337 |
+
"type": "logo",
|
| 338 |
+
"required": true
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"id": "event_logo",
|
| 342 |
+
"label": "Event Logo",
|
| 343 |
+
"type": "logo",
|
| 344 |
+
"required": true
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"id": "greeting",
|
| 348 |
+
"label": "Greeting with First Name Merge Tag",
|
| 349 |
+
"type": "text",
|
| 350 |
+
"required": true,
|
| 351 |
+
"expected_phrases": [
|
| 352 |
+
"Dear Dr",
|
| 353 |
+
"*|FNAME|*"
|
| 354 |
+
]
|
| 355 |
+
},
|
| 356 |
+
{
|
| 357 |
+
"id": "invite_phrase",
|
| 358 |
+
"label": "Invitation Phrase",
|
| 359 |
+
"type": "text",
|
| 360 |
+
"required": true,
|
| 361 |
+
"expected_phrases": [
|
| 362 |
+
"You are cordially invited to attend"
|
| 363 |
+
]
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"id": "event_name",
|
| 367 |
+
"label": "Event Name",
|
| 368 |
+
"type": "event_name",
|
| 369 |
+
"required": true
|
| 370 |
+
},
|
| 371 |
+
{
|
| 372 |
+
"id": "event_date",
|
| 373 |
+
"label": "Event Date",
|
| 374 |
+
"type": "date",
|
| 375 |
+
"required": true
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
"id": "time",
|
| 379 |
+
"label": "Time Range",
|
| 380 |
+
"type": "time_range",
|
| 381 |
+
"required": true
|
| 382 |
+
},
|
| 383 |
+
{
|
| 384 |
+
"id": "venue",
|
| 385 |
+
"label": "Venue",
|
| 386 |
+
"type": "venue",
|
| 387 |
+
"required": true
|
| 388 |
+
},
|
| 389 |
+
{
|
| 390 |
+
"id": "chairpersons_block",
|
| 391 |
+
"label": "Chairpersons Block",
|
| 392 |
+
"type": "section",
|
| 393 |
+
"required": true
|
| 394 |
+
},
|
| 395 |
+
{
|
| 396 |
+
"id": "speakers_block",
|
| 397 |
+
"label": "Speakers Block",
|
| 398 |
+
"type": "section",
|
| 399 |
+
"required": true
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"id": "agenda_table",
|
| 403 |
+
"label": "Agenda Table",
|
| 404 |
+
"type": "table",
|
| 405 |
+
"required": true,
|
| 406 |
+
"expected_columns": [
|
| 407 |
+
"Time",
|
| 408 |
+
"Topic",
|
| 409 |
+
"Faculty"
|
| 410 |
+
]
|
| 411 |
+
},
|
| 412 |
+
{
|
| 413 |
+
"id": "cta_register",
|
| 414 |
+
"label": "CTA to Register",
|
| 415 |
+
"type": "cta",
|
| 416 |
+
"required": true,
|
| 417 |
+
"expected_phrases": [
|
| 418 |
+
"Click here to register"
|
| 419 |
+
]
|
| 420 |
+
},
|
| 421 |
+
{
|
| 422 |
+
"id": "disclaimer",
|
| 423 |
+
"label": "Disclaimer",
|
| 424 |
+
"type": "text",
|
| 425 |
+
"required": true
|
| 426 |
+
},
|
| 427 |
+
{
|
| 428 |
+
"id": "preparation_date",
|
| 429 |
+
"label": "Preparation Date",
|
| 430 |
+
"type": "date_or_placeholder",
|
| 431 |
+
"required": true
|
| 432 |
+
},
|
| 433 |
+
{
|
| 434 |
+
"id": "approval_code",
|
| 435 |
+
"label": "Approval Code",
|
| 436 |
+
"type": "code",
|
| 437 |
+
"required": true
|
| 438 |
+
}
|
| 439 |
]
|
| 440 |
},
|
| 441 |
{
|
| 442 |
"template_key": "pdf_invitation",
|
| 443 |
"friendly_name": "PDF Invitation",
|
| 444 |
"elements": [
|
| 445 |
+
{
|
| 446 |
+
"id": "company_logo",
|
| 447 |
+
"label": "Company Logo",
|
| 448 |
+
"type": "logo",
|
| 449 |
+
"required": true
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
"id": "event_logo",
|
| 453 |
+
"label": "Event Logo",
|
| 454 |
+
"type": "logo",
|
| 455 |
+
"required": true
|
| 456 |
+
},
|
| 457 |
+
{
|
| 458 |
+
"id": "greeting",
|
| 459 |
+
"label": "Greeting (Dear Doctor)",
|
| 460 |
+
"type": "text",
|
| 461 |
+
"required": true,
|
| 462 |
+
"expected_phrases": [
|
| 463 |
+
"Dear Doctor"
|
| 464 |
+
]
|
| 465 |
+
},
|
| 466 |
+
{
|
| 467 |
+
"id": "invite_phrase",
|
| 468 |
+
"label": "Invitation Phrase",
|
| 469 |
+
"type": "text",
|
| 470 |
+
"required": true,
|
| 471 |
+
"expected_phrases": [
|
| 472 |
+
"You are cordially invited to attend"
|
| 473 |
+
]
|
| 474 |
+
},
|
| 475 |
+
{
|
| 476 |
+
"id": "event_name",
|
| 477 |
+
"label": "Event Name",
|
| 478 |
+
"type": "event_name",
|
| 479 |
+
"required": true
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"id": "date",
|
| 483 |
+
"label": "Date",
|
| 484 |
+
"type": "date",
|
| 485 |
+
"required": true
|
| 486 |
+
},
|
| 487 |
+
{
|
| 488 |
+
"id": "time",
|
| 489 |
+
"label": "Time Range",
|
| 490 |
+
"type": "time_range",
|
| 491 |
+
"required": true
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"id": "venue",
|
| 495 |
+
"label": "Venue",
|
| 496 |
+
"type": "venue",
|
| 497 |
+
"required": true
|
| 498 |
+
},
|
| 499 |
+
{
|
| 500 |
+
"id": "chairpersons_block",
|
| 501 |
+
"label": "Chairpersons Block",
|
| 502 |
+
"type": "section",
|
| 503 |
+
"required": true
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"id": "speakers_block",
|
| 507 |
+
"label": "Speakers Block",
|
| 508 |
+
"type": "section",
|
| 509 |
+
"required": true
|
| 510 |
+
},
|
| 511 |
+
{
|
| 512 |
+
"id": "agenda_table",
|
| 513 |
+
"label": "Agenda Table",
|
| 514 |
+
"type": "table",
|
| 515 |
+
"required": true
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"id": "cta_register",
|
| 519 |
+
"label": "CTA to Register",
|
| 520 |
+
"type": "cta",
|
| 521 |
+
"required": true
|
| 522 |
+
},
|
| 523 |
+
{
|
| 524 |
+
"id": "disclaimer",
|
| 525 |
+
"label": "Disclaimer",
|
| 526 |
+
"type": "text",
|
| 527 |
+
"required": true
|
| 528 |
+
},
|
| 529 |
+
{
|
| 530 |
+
"id": "preparation_date",
|
| 531 |
+
"label": "Preparation Date",
|
| 532 |
+
"type": "date_or_placeholder",
|
| 533 |
+
"required": true
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
"id": "approval_code",
|
| 537 |
+
"label": "Approval Code",
|
| 538 |
+
"type": "code",
|
| 539 |
+
"required": true
|
| 540 |
+
}
|
| 541 |
]
|
| 542 |
},
|
| 543 |
{
|
| 544 |
"template_key": "pdf_save_the_date",
|
| 545 |
"friendly_name": "PDF Save the Date",
|
| 546 |
"elements": [
|
| 547 |
+
{
|
| 548 |
+
"id": "company_logo",
|
| 549 |
+
"label": "Company Logo",
|
| 550 |
+
"type": "logo",
|
| 551 |
+
"required": true
|
| 552 |
+
},
|
| 553 |
+
{
|
| 554 |
+
"id": "header_save_the_date",
|
| 555 |
+
"label": "Save the Date Header",
|
| 556 |
+
"type": "text",
|
| 557 |
+
"required": true,
|
| 558 |
+
"expected_phrases": [
|
| 559 |
+
"Save the Date"
|
| 560 |
+
]
|
| 561 |
+
},
|
| 562 |
+
{
|
| 563 |
+
"id": "event_name",
|
| 564 |
+
"label": "Event Name",
|
| 565 |
+
"type": "event_name",
|
| 566 |
+
"required": true
|
| 567 |
+
},
|
| 568 |
+
{
|
| 569 |
+
"id": "event_logo",
|
| 570 |
+
"label": "Event Logo",
|
| 571 |
+
"type": "logo",
|
| 572 |
+
"required": true
|
| 573 |
+
},
|
| 574 |
+
{
|
| 575 |
+
"id": "event_date",
|
| 576 |
+
"label": "Event Date",
|
| 577 |
+
"type": "date",
|
| 578 |
+
"required": true
|
| 579 |
+
},
|
| 580 |
+
{
|
| 581 |
+
"id": "time",
|
| 582 |
+
"label": "Time",
|
| 583 |
+
"type": "time",
|
| 584 |
+
"required": true
|
| 585 |
+
},
|
| 586 |
+
{
|
| 587 |
+
"id": "venue",
|
| 588 |
+
"label": "Venue",
|
| 589 |
+
"type": "venue",
|
| 590 |
+
"required": true
|
| 591 |
+
},
|
| 592 |
+
{
|
| 593 |
+
"id": "cta_add_to_calendar",
|
| 594 |
+
"label": "Add to Calendar CTA",
|
| 595 |
+
"type": "cta",
|
| 596 |
+
"required": true,
|
| 597 |
+
"expected_phrases": [
|
| 598 |
+
"Click here to add to your calendar"
|
| 599 |
+
]
|
| 600 |
+
},
|
| 601 |
+
{
|
| 602 |
+
"id": "chairpersons_block",
|
| 603 |
+
"label": "Chairpersons Block",
|
| 604 |
+
"type": "section",
|
| 605 |
+
"required": false
|
| 606 |
+
},
|
| 607 |
+
{
|
| 608 |
+
"id": "speakers_block",
|
| 609 |
+
"label": "Speakers Block",
|
| 610 |
+
"type": "section",
|
| 611 |
+
"required": false
|
| 612 |
+
},
|
| 613 |
+
{
|
| 614 |
+
"id": "disclaimer",
|
| 615 |
+
"label": "Disclaimer",
|
| 616 |
+
"type": "text",
|
| 617 |
+
"required": true
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"id": "preparation_date",
|
| 621 |
+
"label": "Preparation Date",
|
| 622 |
+
"type": "date_or_placeholder",
|
| 623 |
+
"required": true
|
| 624 |
+
},
|
| 625 |
+
{
|
| 626 |
+
"id": "approval_code",
|
| 627 |
+
"label": "Approval Code",
|
| 628 |
+
"type": "code",
|
| 629 |
+
"required": true
|
| 630 |
+
}
|
| 631 |
]
|
| 632 |
},
|
| 633 |
{
|
| 634 |
"template_key": "printed_invitation",
|
| 635 |
"friendly_name": "Printed Invitation",
|
| 636 |
"elements": [
|
| 637 |
+
{
|
| 638 |
+
"id": "company_logo_top",
|
| 639 |
+
"label": "Top Company Logo",
|
| 640 |
+
"type": "logo",
|
| 641 |
+
"required": true
|
| 642 |
+
},
|
| 643 |
+
{
|
| 644 |
+
"id": "invitation_header",
|
| 645 |
+
"label": "Invitation Header",
|
| 646 |
+
"type": "text",
|
| 647 |
+
"required": true,
|
| 648 |
+
"expected_phrases": [
|
| 649 |
+
"Invitation"
|
| 650 |
+
]
|
| 651 |
+
},
|
| 652 |
+
{
|
| 653 |
+
"id": "event_name",
|
| 654 |
+
"label": "Event Name",
|
| 655 |
+
"type": "event_name",
|
| 656 |
+
"required": true
|
| 657 |
+
},
|
| 658 |
+
{
|
| 659 |
+
"id": "event_logo",
|
| 660 |
+
"label": "Event Logo",
|
| 661 |
+
"type": "logo",
|
| 662 |
+
"required": true
|
| 663 |
+
},
|
| 664 |
+
{
|
| 665 |
+
"id": "event_date",
|
| 666 |
+
"label": "Event Date",
|
| 667 |
+
"type": "date",
|
| 668 |
+
"required": true
|
| 669 |
+
},
|
| 670 |
+
{
|
| 671 |
+
"id": "time",
|
| 672 |
+
"label": "Time Range",
|
| 673 |
+
"type": "time_range",
|
| 674 |
+
"required": true
|
| 675 |
+
},
|
| 676 |
+
{
|
| 677 |
+
"id": "venue",
|
| 678 |
+
"label": "Venue",
|
| 679 |
+
"type": "venue",
|
| 680 |
+
"required": true
|
| 681 |
+
},
|
| 682 |
+
{
|
| 683 |
+
"id": "welcome_or_greeting",
|
| 684 |
+
"label": "Welcome Note or Greeting",
|
| 685 |
+
"type": "text",
|
| 686 |
+
"required": true,
|
| 687 |
+
"placeholder_examples": [
|
| 688 |
+
"<<Welcome Message>>",
|
| 689 |
+
"Dear Doctor"
|
| 690 |
+
]
|
| 691 |
+
},
|
| 692 |
+
{
|
| 693 |
+
"id": "chairpersons_block",
|
| 694 |
+
"label": "Chairpersons Block",
|
| 695 |
+
"type": "section",
|
| 696 |
+
"required": true
|
| 697 |
+
},
|
| 698 |
+
{
|
| 699 |
+
"id": "speakers_block",
|
| 700 |
+
"label": "Speakers Block",
|
| 701 |
+
"type": "section",
|
| 702 |
+
"required": true
|
| 703 |
+
},
|
| 704 |
+
{
|
| 705 |
+
"id": "agenda_table",
|
| 706 |
+
"label": "Agenda Table",
|
| 707 |
+
"type": "table",
|
| 708 |
+
"required": true
|
| 709 |
+
},
|
| 710 |
+
{
|
| 711 |
+
"id": "qr_registration",
|
| 712 |
+
"label": "QR Code for Registration",
|
| 713 |
+
"type": "qr_code_or_image",
|
| 714 |
+
"required": true
|
| 715 |
+
},
|
| 716 |
+
{
|
| 717 |
+
"id": "company_logo_bottom",
|
| 718 |
+
"label": "Bottom Company Logo",
|
| 719 |
+
"type": "logo",
|
| 720 |
+
"required": true
|
| 721 |
+
},
|
| 722 |
+
{
|
| 723 |
+
"id": "disclaimer",
|
| 724 |
+
"label": "Disclaimer",
|
| 725 |
+
"type": "text",
|
| 726 |
+
"required": true
|
| 727 |
+
},
|
| 728 |
+
{
|
| 729 |
+
"id": "preparation_date",
|
| 730 |
+
"label": "Preparation Date",
|
| 731 |
+
"type": "date_or_placeholder",
|
| 732 |
+
"required": true
|
| 733 |
+
},
|
| 734 |
+
{
|
| 735 |
+
"id": "approval_code",
|
| 736 |
+
"label": "Approval Code",
|
| 737 |
+
"type": "code",
|
| 738 |
+
"required": true
|
| 739 |
+
}
|
| 740 |
]
|
| 741 |
},
|
| 742 |
{
|
| 743 |
"template_key": "rcp_certificate_attendance",
|
| 744 |
"friendly_name": "RCP Certificate of Attendance",
|
| 745 |
"elements": [
|
| 746 |
+
{
|
| 747 |
+
"id": "certificate_title",
|
| 748 |
+
"label": "Certificate Title",
|
| 749 |
+
"type": "text",
|
| 750 |
+
"required": true,
|
| 751 |
+
"expected_phrases": [
|
| 752 |
+
"Royal College of Physicians Certificate of Accreditation"
|
| 753 |
+
]
|
| 754 |
+
},
|
| 755 |
+
{
|
| 756 |
+
"id": "recipient_name",
|
| 757 |
+
"label": "Recipient Name",
|
| 758 |
+
"type": "person_name",
|
| 759 |
+
"required": true,
|
| 760 |
+
"placeholder_examples": [
|
| 761 |
+
"Dr << Name >>"
|
| 762 |
+
]
|
| 763 |
+
},
|
| 764 |
+
{
|
| 765 |
+
"id": "event_name",
|
| 766 |
+
"label": "Event Name",
|
| 767 |
+
"type": "event_name",
|
| 768 |
+
"required": true
|
| 769 |
+
},
|
| 770 |
+
{
|
| 771 |
+
"id": "event_date",
|
| 772 |
+
"label": "Event Date",
|
| 773 |
+
"type": "date",
|
| 774 |
+
"required": true
|
| 775 |
+
},
|
| 776 |
+
{
|
| 777 |
+
"id": "rcp_activity_code",
|
| 778 |
+
"label": "RCP Activity Code Number",
|
| 779 |
+
"type": "code",
|
| 780 |
+
"required": true
|
| 781 |
+
},
|
| 782 |
+
{
|
| 783 |
+
"id": "cpd_statement",
|
| 784 |
+
"label": "CPD Credits Statement",
|
| 785 |
+
"type": "text",
|
| 786 |
+
"required": true,
|
| 787 |
+
"expected_phrases": [
|
| 788 |
+
"approved by the Federation of the Royal Colleges of Physicians of the United Kingdom",
|
| 789 |
+
"category 1 (external) CPD credit"
|
| 790 |
+
]
|
| 791 |
+
},
|
| 792 |
+
{
|
| 793 |
+
"id": "event_chairperson_signature",
|
| 794 |
+
"label": "Event Chairperson Signature",
|
| 795 |
+
"type": "signature_block",
|
| 796 |
+
"required": true
|
| 797 |
+
}
|
| 798 |
]
|
| 799 |
},
|
| 800 |
{
|
| 801 |
"template_key": "agenda",
|
| 802 |
"friendly_name": "Agenda Page",
|
| 803 |
"elements": [
|
| 804 |
+
{
|
| 805 |
+
"id": "company_logo",
|
| 806 |
+
"label": "Company Logo",
|
| 807 |
+
"type": "logo",
|
| 808 |
+
"required": true
|
| 809 |
+
},
|
| 810 |
+
{
|
| 811 |
+
"id": "event_logo",
|
| 812 |
+
"label": "Event Logo",
|
| 813 |
+
"type": "logo",
|
| 814 |
+
"required": true
|
| 815 |
+
},
|
| 816 |
+
{
|
| 817 |
+
"id": "event_name",
|
| 818 |
+
"label": "Event Name",
|
| 819 |
+
"type": "event_name",
|
| 820 |
+
"required": true
|
| 821 |
+
},
|
| 822 |
+
{
|
| 823 |
+
"id": "date",
|
| 824 |
+
"label": "Date",
|
| 825 |
+
"type": "date",
|
| 826 |
+
"required": true
|
| 827 |
+
},
|
| 828 |
+
{
|
| 829 |
+
"id": "venue",
|
| 830 |
+
"label": "Venue",
|
| 831 |
+
"type": "venue",
|
| 832 |
+
"required": true
|
| 833 |
+
},
|
| 834 |
+
{
|
| 835 |
+
"id": "agenda_header",
|
| 836 |
+
"label": "Agenda Header",
|
| 837 |
+
"type": "text",
|
| 838 |
+
"required": true,
|
| 839 |
+
"expected_phrases": [
|
| 840 |
+
"Agenda"
|
| 841 |
+
]
|
| 842 |
+
},
|
| 843 |
+
{
|
| 844 |
+
"id": "agenda_table",
|
| 845 |
+
"label": "Agenda Table",
|
| 846 |
+
"type": "table",
|
| 847 |
+
"required": true,
|
| 848 |
+
"expected_columns": [
|
| 849 |
+
"Time",
|
| 850 |
+
"Topic",
|
| 851 |
+
"Faculty"
|
| 852 |
+
]
|
| 853 |
+
},
|
| 854 |
+
{
|
| 855 |
+
"id": "disclaimer",
|
| 856 |
+
"label": "Disclaimer",
|
| 857 |
+
"type": "text",
|
| 858 |
+
"required": true
|
| 859 |
+
},
|
| 860 |
+
{
|
| 861 |
+
"id": "preparation_date",
|
| 862 |
+
"label": "Preparation Date",
|
| 863 |
+
"type": "date_or_placeholder",
|
| 864 |
+
"required": true
|
| 865 |
+
},
|
| 866 |
+
{
|
| 867 |
+
"id": "approval_code",
|
| 868 |
+
"label": "Approval Code",
|
| 869 |
+
"type": "code",
|
| 870 |
+
"required": true
|
| 871 |
+
}
|
| 872 |
]
|
| 873 |
},
|
| 874 |
{
|
| 875 |
"template_key": "certificate_accreditation_dha_full",
|
| 876 |
"friendly_name": "DHA Certificate of Accreditation (President + Chairs)",
|
| 877 |
"elements": [
|
| 878 |
+
{
|
| 879 |
+
"id": "certificate_title",
|
| 880 |
+
"label": "Certificate Title",
|
| 881 |
+
"type": "text",
|
| 882 |
+
"required": true,
|
| 883 |
+
"expected_phrases": [
|
| 884 |
+
"Certificate of Accreditation"
|
| 885 |
+
]
|
| 886 |
+
},
|
| 887 |
+
{
|
| 888 |
+
"id": "president_name",
|
| 889 |
+
"label": "President Name",
|
| 890 |
+
"type": "person_name",
|
| 891 |
+
"required": true
|
| 892 |
+
},
|
| 893 |
+
{
|
| 894 |
+
"id": "chair_scientific_committee",
|
| 895 |
+
"label": "Chair of Scientific Committee",
|
| 896 |
+
"type": "person_name",
|
| 897 |
+
"required": true
|
| 898 |
+
},
|
| 899 |
+
{
|
| 900 |
+
"id": "chair_organizing_committee",
|
| 901 |
+
"label": "Chair of Organizing Committee",
|
| 902 |
+
"type": "person_name",
|
| 903 |
+
"required": true
|
| 904 |
+
},
|
| 905 |
+
{
|
| 906 |
+
"id": "recipient_name",
|
| 907 |
+
"label": "Recipient Name",
|
| 908 |
+
"type": "person_name",
|
| 909 |
+
"required": true
|
| 910 |
+
},
|
| 911 |
+
{
|
| 912 |
+
"id": "attendance_text",
|
| 913 |
+
"label": "Attendance Confirmation Text",
|
| 914 |
+
"type": "text",
|
| 915 |
+
"required": true,
|
| 916 |
+
"expected_phrases": [
|
| 917 |
+
"This certificate confirms that",
|
| 918 |
+
"Has attended"
|
| 919 |
+
]
|
| 920 |
+
},
|
| 921 |
+
{
|
| 922 |
+
"id": "event_name",
|
| 923 |
+
"label": "Event Name",
|
| 924 |
+
"type": "event_name",
|
| 925 |
+
"required": true
|
| 926 |
+
},
|
| 927 |
+
{
|
| 928 |
+
"id": "cpd_points",
|
| 929 |
+
"label": "CPD Credit Points",
|
| 930 |
+
"type": "number_or_text",
|
| 931 |
+
"required": true,
|
| 932 |
+
"expected_phrases": [
|
| 933 |
+
"Awarded",
|
| 934 |
+
"CPD Credit Point",
|
| 935 |
+
"Dubai Health Authority"
|
| 936 |
+
]
|
| 937 |
+
},
|
| 938 |
+
{
|
| 939 |
+
"id": "accreditation_code",
|
| 940 |
+
"label": "Accreditation Code",
|
| 941 |
+
"type": "code",
|
| 942 |
+
"required": true,
|
| 943 |
+
"expected_phrases": [
|
| 944 |
+
"Accreditation #",
|
| 945 |
+
"CODE"
|
| 946 |
+
]
|
| 947 |
+
},
|
| 948 |
+
{
|
| 949 |
+
"id": "completion_date",
|
| 950 |
+
"label": "Completion Date",
|
| 951 |
+
"type": "date",
|
| 952 |
+
"required": true
|
| 953 |
+
},
|
| 954 |
+
{
|
| 955 |
+
"id": "venue",
|
| 956 |
+
"label": "Venue & Location",
|
| 957 |
+
"type": "venue",
|
| 958 |
+
"required": true
|
| 959 |
+
}
|
| 960 |
]
|
| 961 |
},
|
| 962 |
{
|
| 963 |
"template_key": "certificate_appreciation_sponsor",
|
| 964 |
"friendly_name": "Certificate of Appreciation (Sponsor)",
|
| 965 |
"elements": [
|
| 966 |
+
{
|
| 967 |
+
"id": "certificate_title",
|
| 968 |
+
"label": "Certificate Title",
|
| 969 |
+
"type": "text",
|
| 970 |
+
"required": true,
|
| 971 |
+
"expected_phrases": [
|
| 972 |
+
"Certificate of Appreciation"
|
| 973 |
+
]
|
| 974 |
+
},
|
| 975 |
+
{
|
| 976 |
+
"id": "recipient_company_name",
|
| 977 |
+
"label": "Sponsor Company Name",
|
| 978 |
+
"type": "company_name",
|
| 979 |
+
"required": true,
|
| 980 |
+
"placeholder_examples": [
|
| 981 |
+
"<< Company Name >>"
|
| 982 |
+
]
|
| 983 |
+
},
|
| 984 |
+
{
|
| 985 |
+
"id": "purpose_text",
|
| 986 |
+
"label": "Purpose Text",
|
| 987 |
+
"type": "text",
|
| 988 |
+
"required": true,
|
| 989 |
+
"expected_phrases": [
|
| 990 |
+
"to express our gratitude and appreciation for generously supporting"
|
| 991 |
+
]
|
| 992 |
+
},
|
| 993 |
+
{
|
| 994 |
+
"id": "event_name",
|
| 995 |
+
"label": "Event Name",
|
| 996 |
+
"type": "event_name",
|
| 997 |
+
"required": true
|
| 998 |
+
},
|
| 999 |
+
{
|
| 1000 |
+
"id": "event_date",
|
| 1001 |
+
"label": "Event Date",
|
| 1002 |
+
"type": "date",
|
| 1003 |
+
"required": true
|
| 1004 |
+
},
|
| 1005 |
+
{
|
| 1006 |
+
"id": "venue",
|
| 1007 |
+
"label": "Venue",
|
| 1008 |
+
"type": "venue",
|
| 1009 |
+
"required": true
|
| 1010 |
+
},
|
| 1011 |
+
{
|
| 1012 |
+
"id": "event_chairperson_signature",
|
| 1013 |
+
"label": "Event Chairperson Signature",
|
| 1014 |
+
"type": "signature_block",
|
| 1015 |
+
"required": true
|
| 1016 |
+
},
|
| 1017 |
+
{
|
| 1018 |
+
"id": "event_logo",
|
| 1019 |
+
"label": "Event Logo",
|
| 1020 |
+
"type": "logo",
|
| 1021 |
+
"required": true
|
| 1022 |
+
},
|
| 1023 |
+
{
|
| 1024 |
+
"id": "company_logo",
|
| 1025 |
+
"label": "Company Logo",
|
| 1026 |
+
"type": "logo",
|
| 1027 |
+
"required": true
|
| 1028 |
+
}
|
| 1029 |
]
|
| 1030 |
},
|
| 1031 |
{
|
| 1032 |
"template_key": "evaluation_form_post_event",
|
| 1033 |
"friendly_name": "Evaluation Form (Post-Event)",
|
| 1034 |
"elements": [
|
| 1035 |
+
{
|
| 1036 |
+
"id": "header_title",
|
| 1037 |
+
"label": "Header Title",
|
| 1038 |
+
"type": "text",
|
| 1039 |
+
"required": true,
|
| 1040 |
+
"expected_phrases": [
|
| 1041 |
+
"Evaluation and Feedback"
|
| 1042 |
+
]
|
| 1043 |
+
},
|
| 1044 |
+
{
|
| 1045 |
+
"id": "company_reference",
|
| 1046 |
+
"label": "Company Medical & Scientific Affairs Reference",
|
| 1047 |
+
"type": "text",
|
| 1048 |
+
"required": true
|
| 1049 |
+
},
|
| 1050 |
+
{
|
| 1051 |
+
"id": "event_name",
|
| 1052 |
+
"label": "Event Name",
|
| 1053 |
+
"type": "event_name",
|
| 1054 |
+
"required": true
|
| 1055 |
+
},
|
| 1056 |
+
{
|
| 1057 |
+
"id": "likert_questions",
|
| 1058 |
+
"label": "Likert Scale Questions (1–8)",
|
| 1059 |
+
"type": "list_of_questions",
|
| 1060 |
+
"required": true
|
| 1061 |
+
},
|
| 1062 |
+
{
|
| 1063 |
+
"id": "open_question_learnings",
|
| 1064 |
+
"label": "Open Question: Key Learnings",
|
| 1065 |
+
"type": "open_question",
|
| 1066 |
+
"required": true
|
| 1067 |
+
},
|
| 1068 |
+
{
|
| 1069 |
+
"id": "open_question_future_topics",
|
| 1070 |
+
"label": "Open Question: Future Topics",
|
| 1071 |
+
"type": "open_question",
|
| 1072 |
+
"required": true
|
| 1073 |
+
},
|
| 1074 |
+
{
|
| 1075 |
+
"id": "optional_name",
|
| 1076 |
+
"label": "Optional Name Field",
|
| 1077 |
+
"type": "person_name_field",
|
| 1078 |
+
"required": false
|
| 1079 |
+
},
|
| 1080 |
+
{
|
| 1081 |
+
"id": "optional_title",
|
| 1082 |
+
"label": "Optional Title Field",
|
| 1083 |
+
"type": "text_field",
|
| 1084 |
+
"required": false
|
| 1085 |
+
}
|
| 1086 |
]
|
| 1087 |
},
|
| 1088 |
{
|
| 1089 |
"template_key": "event_booklet",
|
| 1090 |
"friendly_name": "Event Booklet",
|
| 1091 |
"elements": [
|
| 1092 |
+
{
|
| 1093 |
+
"id": "back_cover_logos",
|
| 1094 |
+
"label": "Back Cover Logos",
|
| 1095 |
+
"type": "logo_group",
|
| 1096 |
+
"required": true
|
| 1097 |
+
},
|
| 1098 |
+
{
|
| 1099 |
+
"id": "back_cover_disclaimer",
|
| 1100 |
+
"label": "Back Cover Disclaimer",
|
| 1101 |
+
"type": "text",
|
| 1102 |
+
"required": true
|
| 1103 |
+
},
|
| 1104 |
+
{
|
| 1105 |
+
"id": "back_cover_prep_approval",
|
| 1106 |
+
"label": "Back Cover Preparation Date & Approval Code",
|
| 1107 |
+
"type": "date_and_code",
|
| 1108 |
+
"required": true
|
| 1109 |
+
},
|
| 1110 |
+
{
|
| 1111 |
+
"id": "front_cover_logos",
|
| 1112 |
+
"label": "Front Cover Logos",
|
| 1113 |
+
"type": "logo_group",
|
| 1114 |
+
"required": true
|
| 1115 |
+
},
|
| 1116 |
+
{
|
| 1117 |
+
"id": "front_cover_event_name",
|
| 1118 |
+
"label": "Front Cover Event Name",
|
| 1119 |
+
"type": "event_name",
|
| 1120 |
+
"required": true
|
| 1121 |
+
},
|
| 1122 |
+
{
|
| 1123 |
+
"id": "front_cover_company_logo",
|
| 1124 |
+
"label": "Front Cover Company Logo",
|
| 1125 |
+
"type": "logo",
|
| 1126 |
+
"required": true
|
| 1127 |
+
},
|
| 1128 |
+
{
|
| 1129 |
+
"id": "event_details_block",
|
| 1130 |
+
"label": "Event Details Block (Date/Time/Venue)",
|
| 1131 |
+
"type": "section",
|
| 1132 |
+
"required": true
|
| 1133 |
+
},
|
| 1134 |
+
{
|
| 1135 |
+
"id": "welcome_note",
|
| 1136 |
+
"label": "Welcome Note",
|
| 1137 |
+
"type": "text",
|
| 1138 |
+
"required": true
|
| 1139 |
+
},
|
| 1140 |
+
{
|
| 1141 |
+
"id": "summary_objectives",
|
| 1142 |
+
"label": "Summary & Objectives",
|
| 1143 |
+
"type": "text",
|
| 1144 |
+
"required": true
|
| 1145 |
+
},
|
| 1146 |
+
{
|
| 1147 |
+
"id": "accreditation_information",
|
| 1148 |
+
"label": "Accreditation Information",
|
| 1149 |
+
"type": "text",
|
| 1150 |
+
"required": true
|
| 1151 |
+
},
|
| 1152 |
+
{
|
| 1153 |
+
"id": "program_description",
|
| 1154 |
+
"label": "Program Description",
|
| 1155 |
+
"type": "text",
|
| 1156 |
+
"required": true
|
| 1157 |
+
},
|
| 1158 |
+
{
|
| 1159 |
+
"id": "agenda_table",
|
| 1160 |
+
"label": "Agenda Table",
|
| 1161 |
+
"type": "table",
|
| 1162 |
+
"required": true
|
| 1163 |
+
},
|
| 1164 |
+
{
|
| 1165 |
+
"id": "faculty_section",
|
| 1166 |
+
"label": "Faculty Section (Chairpersons & Speakers)",
|
| 1167 |
+
"type": "section",
|
| 1168 |
+
"required": true
|
| 1169 |
+
},
|
| 1170 |
+
{
|
| 1171 |
+
"id": "speaker_page_structure",
|
| 1172 |
+
"label": "Speaker Pages Structure",
|
| 1173 |
+
"type": "section",
|
| 1174 |
+
"required": true,
|
| 1175 |
+
"expected_subfields": [
|
| 1176 |
+
"Speaker's name",
|
| 1177 |
+
"Bio",
|
| 1178 |
+
"Topic",
|
| 1179 |
+
"Time",
|
| 1180 |
+
"Short bio",
|
| 1181 |
+
"Objective",
|
| 1182 |
+
"Notes"
|
| 1183 |
+
]
|
| 1184 |
+
},
|
| 1185 |
+
{
|
| 1186 |
+
"id": "notes_section",
|
| 1187 |
+
"label": "Notes Page",
|
| 1188 |
+
"type": "notes_area",
|
| 1189 |
+
"required": false
|
| 1190 |
+
},
|
| 1191 |
+
{
|
| 1192 |
+
"id": "sponsors_logos",
|
| 1193 |
+
"label": "Sponsors Logos Section",
|
| 1194 |
+
"type": "logo_group",
|
| 1195 |
+
"required": true
|
| 1196 |
+
}
|
| 1197 |
]
|
| 1198 |
},
|
| 1199 |
{
|
| 1200 |
"template_key": "landing_page_registration",
|
| 1201 |
"friendly_name": "Landing Page & Registration",
|
| 1202 |
"elements": [
|
| 1203 |
+
{
|
| 1204 |
+
"id": "company_logo",
|
| 1205 |
+
"label": "Company Logo",
|
| 1206 |
+
"type": "logo",
|
| 1207 |
+
"required": true
|
| 1208 |
+
},
|
| 1209 |
+
{
|
| 1210 |
+
"id": "event_logo",
|
| 1211 |
+
"label": "Event Logo",
|
| 1212 |
+
"type": "logo",
|
| 1213 |
+
"required": true
|
| 1214 |
+
},
|
| 1215 |
+
{
|
| 1216 |
+
"id": "welcome_note",
|
| 1217 |
+
"label": "Welcome Note",
|
| 1218 |
+
"type": "text",
|
| 1219 |
+
"required": true
|
| 1220 |
+
},
|
| 1221 |
+
{
|
| 1222 |
+
"id": "event_name",
|
| 1223 |
+
"label": "Event Name",
|
| 1224 |
+
"type": "event_name",
|
| 1225 |
+
"required": true
|
| 1226 |
+
},
|
| 1227 |
+
{
|
| 1228 |
+
"id": "event_overview",
|
| 1229 |
+
"label": "Event Overview & Details",
|
| 1230 |
+
"type": "text",
|
| 1231 |
+
"required": true
|
| 1232 |
+
},
|
| 1233 |
+
{
|
| 1234 |
+
"id": "date",
|
| 1235 |
+
"label": "Event Date",
|
| 1236 |
+
"type": "date",
|
| 1237 |
+
"required": true
|
| 1238 |
+
},
|
| 1239 |
+
{
|
| 1240 |
+
"id": "time",
|
| 1241 |
+
"label": "Time Range",
|
| 1242 |
+
"type": "time_range",
|
| 1243 |
+
"required": true
|
| 1244 |
+
},
|
| 1245 |
+
{
|
| 1246 |
+
"id": "venue",
|
| 1247 |
+
"label": "Venue",
|
| 1248 |
+
"type": "venue",
|
| 1249 |
+
"required": true
|
| 1250 |
+
},
|
| 1251 |
+
{
|
| 1252 |
+
"id": "chairpersons_block",
|
| 1253 |
+
"label": "Chairpersons Block",
|
| 1254 |
+
"type": "section",
|
| 1255 |
+
"required": true
|
| 1256 |
+
},
|
| 1257 |
+
{
|
| 1258 |
+
"id": "speakers_block",
|
| 1259 |
+
"label": "Speakers Block",
|
| 1260 |
+
"type": "section",
|
| 1261 |
+
"required": true
|
| 1262 |
+
},
|
| 1263 |
+
{
|
| 1264 |
+
"id": "agenda_table",
|
| 1265 |
+
"label": "Agenda Table",
|
| 1266 |
+
"type": "table",
|
| 1267 |
+
"required": true
|
| 1268 |
+
},
|
| 1269 |
+
{
|
| 1270 |
+
"id": "cta_register_buttons",
|
| 1271 |
+
"label": "Register Buttons",
|
| 1272 |
+
"type": "cta",
|
| 1273 |
+
"required": true,
|
| 1274 |
+
"expected_phrases": [
|
| 1275 |
+
"Click here to register"
|
| 1276 |
+
]
|
| 1277 |
+
},
|
| 1278 |
+
{
|
| 1279 |
+
"id": "countdown",
|
| 1280 |
+
"label": "Time Countdown Widget/Placeholder",
|
| 1281 |
+
"type": "widget_or_placeholder",
|
| 1282 |
+
"required": false
|
| 1283 |
+
},
|
| 1284 |
+
{
|
| 1285 |
+
"id": "disclaimer",
|
| 1286 |
+
"label": "Disclaimer",
|
| 1287 |
+
"type": "text",
|
| 1288 |
+
"required": true
|
| 1289 |
+
},
|
| 1290 |
+
{
|
| 1291 |
+
"id": "preparation_date",
|
| 1292 |
+
"label": "Preparation Date",
|
| 1293 |
+
"type": "date_or_placeholder",
|
| 1294 |
+
"required": true
|
| 1295 |
+
},
|
| 1296 |
+
{
|
| 1297 |
+
"id": "approval_code",
|
| 1298 |
+
"label": "Approval Code",
|
| 1299 |
+
"type": "code",
|
| 1300 |
+
"required": true
|
| 1301 |
+
},
|
| 1302 |
+
{
|
| 1303 |
+
"id": "form_first_name",
|
| 1304 |
+
"label": "Form Field: First Name",
|
| 1305 |
+
"type": "text_field",
|
| 1306 |
+
"required": true
|
| 1307 |
+
},
|
| 1308 |
+
{
|
| 1309 |
+
"id": "form_last_name",
|
| 1310 |
+
"label": "Form Field: Last Name",
|
| 1311 |
+
"type": "text_field",
|
| 1312 |
+
"required": true
|
| 1313 |
+
},
|
| 1314 |
+
{
|
| 1315 |
+
"id": "form_phone",
|
| 1316 |
+
"label": "Form Field: Phone",
|
| 1317 |
+
"type": "phone_field",
|
| 1318 |
+
"required": true
|
| 1319 |
+
},
|
| 1320 |
+
{
|
| 1321 |
+
"id": "form_email",
|
| 1322 |
+
"label": "Form Field: Email",
|
| 1323 |
+
"type": "email_field",
|
| 1324 |
+
"required": true
|
| 1325 |
+
},
|
| 1326 |
+
{
|
| 1327 |
+
"id": "form_specialty",
|
| 1328 |
+
"label": "Form Field: Specialty",
|
| 1329 |
+
"type": "select_field",
|
| 1330 |
+
"required": true
|
| 1331 |
+
},
|
| 1332 |
+
{
|
| 1333 |
+
"id": "form_country",
|
| 1334 |
+
"label": "Form Field: Country",
|
| 1335 |
+
"type": "select_field",
|
| 1336 |
+
"required": true
|
| 1337 |
+
},
|
| 1338 |
+
{
|
| 1339 |
+
"id": "form_affiliation",
|
| 1340 |
+
"label": "Form Field: Affiliation/Hospital",
|
| 1341 |
+
"type": "text_field",
|
| 1342 |
+
"required": true
|
| 1343 |
+
},
|
| 1344 |
+
{
|
| 1345 |
+
"id": "form_scfhs",
|
| 1346 |
+
"label": "Form Field: SCFHS (For Saudi only)",
|
| 1347 |
+
"type": "text_field",
|
| 1348 |
+
"required": false
|
| 1349 |
+
},
|
| 1350 |
+
{
|
| 1351 |
+
"id": "mandatory_disclaimer_tick",
|
| 1352 |
+
"label": "Mandatory Disclaimer Tick Box",
|
| 1353 |
+
"type": "checkbox",
|
| 1354 |
+
"required": true
|
| 1355 |
+
}
|
| 1356 |
]
|
| 1357 |
},
|
| 1358 |
{
|
| 1359 |
"template_key": "slides_permission",
|
| 1360 |
"friendly_name": "Slides Permission Form",
|
| 1361 |
"elements": [
|
| 1362 |
+
{
|
| 1363 |
+
"id": "company_logo",
|
| 1364 |
+
"label": "Company Logo",
|
| 1365 |
+
"type": "logo",
|
| 1366 |
+
"required": true
|
| 1367 |
+
},
|
| 1368 |
+
{
|
| 1369 |
+
"id": "event_logo",
|
| 1370 |
+
"label": "Event Logo",
|
| 1371 |
+
"type": "logo",
|
| 1372 |
+
"required": true
|
| 1373 |
+
},
|
| 1374 |
+
{
|
| 1375 |
+
"id": "permission_header",
|
| 1376 |
+
"label": "Permission Header",
|
| 1377 |
+
"type": "text",
|
| 1378 |
+
"required": true,
|
| 1379 |
+
"expected_phrases": [
|
| 1380 |
+
"Permission"
|
| 1381 |
+
]
|
| 1382 |
+
},
|
| 1383 |
+
{
|
| 1384 |
+
"id": "permission_body",
|
| 1385 |
+
"label": "Permission Body Text",
|
| 1386 |
+
"type": "text",
|
| 1387 |
+
"required": true,
|
| 1388 |
+
"expected_phrases": [
|
| 1389 |
+
"give my permission",
|
| 1390 |
+
"share my slides",
|
| 1391 |
+
"PDF Via email",
|
| 1392 |
+
"Video in a webcast mode"
|
| 1393 |
+
]
|
| 1394 |
+
},
|
| 1395 |
+
{
|
| 1396 |
+
"id": "company_name_reference",
|
| 1397 |
+
"label": "Company Name Reference",
|
| 1398 |
+
"type": "company_name",
|
| 1399 |
+
"required": true
|
| 1400 |
+
},
|
| 1401 |
+
{
|
| 1402 |
+
"id": "event_name",
|
| 1403 |
+
"label": "Event Name",
|
| 1404 |
+
"type": "event_name",
|
| 1405 |
+
"required": true
|
| 1406 |
+
},
|
| 1407 |
+
{
|
| 1408 |
+
"id": "event_date",
|
| 1409 |
+
"label": "Event Date",
|
| 1410 |
+
"type": "date",
|
| 1411 |
+
"required": true
|
| 1412 |
+
},
|
| 1413 |
+
{
|
| 1414 |
+
"id": "comments_section",
|
| 1415 |
+
"label": "Comments Section",
|
| 1416 |
+
"type": "multiline_text_area",
|
| 1417 |
+
"required": false
|
| 1418 |
+
},
|
| 1419 |
+
{
|
| 1420 |
+
"id": "name_field",
|
| 1421 |
+
"label": "Name Field",
|
| 1422 |
+
"type": "person_name_field",
|
| 1423 |
+
"required": true
|
| 1424 |
+
},
|
| 1425 |
+
{
|
| 1426 |
+
"id": "signature_field",
|
| 1427 |
+
"label": "Signature Field",
|
| 1428 |
+
"type": "signature_block",
|
| 1429 |
+
"required": true
|
| 1430 |
+
}
|
| 1431 |
+
]
|
| 1432 |
+
},
|
| 1433 |
+
{
|
| 1434 |
+
"template_key": "backdrop",
|
| 1435 |
+
"friendly_name": "Event Backdrop",
|
| 1436 |
+
"elements": [
|
| 1437 |
+
{
|
| 1438 |
+
"id": "event_visuals",
|
| 1439 |
+
"label": "Event Visuals",
|
| 1440 |
+
"type": "visual_elements",
|
| 1441 |
+
"required": true
|
| 1442 |
+
},
|
| 1443 |
+
{
|
| 1444 |
+
"id": "event_logo",
|
| 1445 |
+
"label": "Event Logo",
|
| 1446 |
+
"type": "logo",
|
| 1447 |
+
"required": true,
|
| 1448 |
+
"logo_role": "event"
|
| 1449 |
+
},
|
| 1450 |
+
{
|
| 1451 |
+
"id": "company_logo",
|
| 1452 |
+
"label": "Company Logo",
|
| 1453 |
+
"type": "logo",
|
| 1454 |
+
"required": true,
|
| 1455 |
+
"logo_role": "company"
|
| 1456 |
+
},
|
| 1457 |
+
{
|
| 1458 |
+
"id": "disclaimers",
|
| 1459 |
+
"label": "Disclaimers",
|
| 1460 |
+
"type": "text",
|
| 1461 |
+
"required": true
|
| 1462 |
+
},
|
| 1463 |
+
{
|
| 1464 |
+
"id": "preparation_date",
|
| 1465 |
+
"label": "Preparation Date",
|
| 1466 |
+
"type": "date_or_placeholder",
|
| 1467 |
+
"required": true
|
| 1468 |
+
},
|
| 1469 |
+
{
|
| 1470 |
+
"id": "approval_code",
|
| 1471 |
+
"label": "Approval Code",
|
| 1472 |
+
"type": "code",
|
| 1473 |
+
"required": true
|
| 1474 |
+
}
|
| 1475 |
+
]
|
| 1476 |
+
},
|
| 1477 |
+
{
|
| 1478 |
+
"template_key": "registration_desk",
|
| 1479 |
+
"friendly_name": "Registration Desk Sign",
|
| 1480 |
+
"elements": [
|
| 1481 |
+
{
|
| 1482 |
+
"id": "event_visuals_or_logo",
|
| 1483 |
+
"label": "Event Visuals or Logo",
|
| 1484 |
+
"type": "visual_or_logo",
|
| 1485 |
+
"required": true
|
| 1486 |
+
},
|
| 1487 |
+
{
|
| 1488 |
+
"id": "preparation_date",
|
| 1489 |
+
"label": "Preparation Date",
|
| 1490 |
+
"type": "date_or_placeholder",
|
| 1491 |
+
"required": true
|
| 1492 |
+
},
|
| 1493 |
+
{
|
| 1494 |
+
"id": "approval_code",
|
| 1495 |
+
"label": "Approval Code",
|
| 1496 |
+
"type": "code",
|
| 1497 |
+
"required": true
|
| 1498 |
+
}
|
| 1499 |
+
]
|
| 1500 |
+
},
|
| 1501 |
+
{
|
| 1502 |
+
"template_key": "registration_desk_table_tent",
|
| 1503 |
+
"friendly_name": "Registration Desk Table Tent",
|
| 1504 |
+
"elements": [
|
| 1505 |
+
{
|
| 1506 |
+
"id": "event_logo",
|
| 1507 |
+
"label": "Event Logo",
|
| 1508 |
+
"type": "logo",
|
| 1509 |
+
"required": true,
|
| 1510 |
+
"logo_role": "event"
|
| 1511 |
+
},
|
| 1512 |
+
{
|
| 1513 |
+
"id": "country_or_city",
|
| 1514 |
+
"label": "Country/City",
|
| 1515 |
+
"type": "location",
|
| 1516 |
+
"required": true
|
| 1517 |
+
},
|
| 1518 |
+
{
|
| 1519 |
+
"id": "registration_status",
|
| 1520 |
+
"label": "Registered/Non-registered Label",
|
| 1521 |
+
"type": "text",
|
| 1522 |
+
"required": true,
|
| 1523 |
+
"expected_phrases": [
|
| 1524 |
+
"Registered",
|
| 1525 |
+
"Nonregistered"
|
| 1526 |
+
]
|
| 1527 |
+
},
|
| 1528 |
+
{
|
| 1529 |
+
"id": "preparation_date",
|
| 1530 |
+
"label": "Preparation Date",
|
| 1531 |
+
"type": "date_or_placeholder",
|
| 1532 |
+
"required": true
|
| 1533 |
+
},
|
| 1534 |
+
{
|
| 1535 |
+
"id": "approval_code",
|
| 1536 |
+
"label": "Approval Code",
|
| 1537 |
+
"type": "code",
|
| 1538 |
+
"required": true
|
| 1539 |
+
}
|
| 1540 |
+
]
|
| 1541 |
+
},
|
| 1542 |
+
{
|
| 1543 |
+
"template_key": "name_tags",
|
| 1544 |
+
"friendly_name": "Name Tags",
|
| 1545 |
+
"elements": [
|
| 1546 |
+
{
|
| 1547 |
+
"id": "doctor_name",
|
| 1548 |
+
"label": "Doctor Name Placeholder",
|
| 1549 |
+
"type": "person_name",
|
| 1550 |
+
"required": true,
|
| 1551 |
+
"placeholder_examples": [
|
| 1552 |
+
"Dr Name"
|
| 1553 |
+
]
|
| 1554 |
+
},
|
| 1555 |
+
{
|
| 1556 |
+
"id": "company_logo",
|
| 1557 |
+
"label": "Company Logo",
|
| 1558 |
+
"type": "logo",
|
| 1559 |
+
"required": true,
|
| 1560 |
+
"logo_role": "company"
|
| 1561 |
+
},
|
| 1562 |
+
{
|
| 1563 |
+
"id": "event_logo",
|
| 1564 |
+
"label": "Event Logo",
|
| 1565 |
+
"type": "logo",
|
| 1566 |
+
"required": true,
|
| 1567 |
+
"logo_role": "event"
|
| 1568 |
+
}
|
| 1569 |
+
]
|
| 1570 |
+
},
|
| 1571 |
+
{
|
| 1572 |
+
"template_key": "signage",
|
| 1573 |
+
"friendly_name": "Room Signage",
|
| 1574 |
+
"elements": [
|
| 1575 |
+
{
|
| 1576 |
+
"id": "room_name",
|
| 1577 |
+
"label": "Room Name",
|
| 1578 |
+
"type": "text",
|
| 1579 |
+
"required": true
|
| 1580 |
+
},
|
| 1581 |
+
{
|
| 1582 |
+
"id": "organized_by",
|
| 1583 |
+
"label": "Organized By Text",
|
| 1584 |
+
"type": "text",
|
| 1585 |
+
"required": true,
|
| 1586 |
+
"expected_phrases": [
|
| 1587 |
+
"Organized by"
|
| 1588 |
+
]
|
| 1589 |
+
},
|
| 1590 |
+
{
|
| 1591 |
+
"id": "company_logo",
|
| 1592 |
+
"label": "Company Logo",
|
| 1593 |
+
"type": "logo",
|
| 1594 |
+
"required": true,
|
| 1595 |
+
"logo_role": "company"
|
| 1596 |
+
},
|
| 1597 |
+
{
|
| 1598 |
+
"id": "event_logo",
|
| 1599 |
+
"label": "Event Logo",
|
| 1600 |
+
"type": "logo",
|
| 1601 |
+
"required": true,
|
| 1602 |
+
"logo_role": "event"
|
| 1603 |
+
}
|
| 1604 |
+
]
|
| 1605 |
+
},
|
| 1606 |
+
{
|
| 1607 |
+
"template_key": "airport_signage",
|
| 1608 |
+
"friendly_name": "Airport Signage (A3 size)",
|
| 1609 |
+
"elements": [
|
| 1610 |
+
{
|
| 1611 |
+
"id": "organized_by",
|
| 1612 |
+
"label": "Organized By Text",
|
| 1613 |
+
"type": "text",
|
| 1614 |
+
"required": true,
|
| 1615 |
+
"expected_phrases": [
|
| 1616 |
+
"Organized by"
|
| 1617 |
+
]
|
| 1618 |
+
},
|
| 1619 |
+
{
|
| 1620 |
+
"id": "event_logo",
|
| 1621 |
+
"label": "Event Logo",
|
| 1622 |
+
"type": "logo",
|
| 1623 |
+
"required": true,
|
| 1624 |
+
"logo_role": "event"
|
| 1625 |
+
},
|
| 1626 |
+
{
|
| 1627 |
+
"id": "doctor_name",
|
| 1628 |
+
"label": "Doctor Name (A4 Space)",
|
| 1629 |
+
"type": "person_name",
|
| 1630 |
+
"required": true,
|
| 1631 |
+
"placeholder_examples": [
|
| 1632 |
+
"Dr Name"
|
| 1633 |
+
]
|
| 1634 |
+
}
|
| 1635 |
+
]
|
| 1636 |
+
},
|
| 1637 |
+
{
|
| 1638 |
+
"template_key": "table_tent_qr",
|
| 1639 |
+
"friendly_name": "Table Tent with QR Code",
|
| 1640 |
+
"elements": [
|
| 1641 |
+
{
|
| 1642 |
+
"id": "instruction_text",
|
| 1643 |
+
"label": "QR Code Instruction Text",
|
| 1644 |
+
"type": "text",
|
| 1645 |
+
"required": true,
|
| 1646 |
+
"expected_phrases": [
|
| 1647 |
+
"Please scan the QR code to ask questions and fill in the meeting evaluation form"
|
| 1648 |
+
]
|
| 1649 |
+
},
|
| 1650 |
+
{
|
| 1651 |
+
"id": "company_logo",
|
| 1652 |
+
"label": "Company Logo",
|
| 1653 |
+
"type": "logo",
|
| 1654 |
+
"required": true,
|
| 1655 |
+
"logo_role": "company"
|
| 1656 |
+
},
|
| 1657 |
+
{
|
| 1658 |
+
"id": "event_logo",
|
| 1659 |
+
"label": "Event Logo",
|
| 1660 |
+
"type": "logo",
|
| 1661 |
+
"required": true,
|
| 1662 |
+
"logo_role": "event"
|
| 1663 |
+
},
|
| 1664 |
+
{
|
| 1665 |
+
"id": "qr_code",
|
| 1666 |
+
"label": "QR Code",
|
| 1667 |
+
"type": "qr_code_or_image",
|
| 1668 |
+
"required": true
|
| 1669 |
+
},
|
| 1670 |
+
{
|
| 1671 |
+
"id": "preparation_date",
|
| 1672 |
+
"label": "Preparation Date",
|
| 1673 |
+
"type": "date_or_placeholder",
|
| 1674 |
+
"required": true
|
| 1675 |
+
},
|
| 1676 |
+
{
|
| 1677 |
+
"id": "approval_code",
|
| 1678 |
+
"label": "Approval Code",
|
| 1679 |
+
"type": "code",
|
| 1680 |
+
"required": true
|
| 1681 |
+
}
|
| 1682 |
+
]
|
| 1683 |
+
},
|
| 1684 |
+
{
|
| 1685 |
+
"template_key": "faculty_table_tent",
|
| 1686 |
+
"friendly_name": "Faculty Table Tent",
|
| 1687 |
+
"elements": [
|
| 1688 |
+
{
|
| 1689 |
+
"id": "doctor_name",
|
| 1690 |
+
"label": "Doctor Name Placeholder",
|
| 1691 |
+
"type": "person_name",
|
| 1692 |
+
"required": true,
|
| 1693 |
+
"placeholder_examples": [
|
| 1694 |
+
"Dr Name"
|
| 1695 |
+
]
|
| 1696 |
+
},
|
| 1697 |
+
{
|
| 1698 |
+
"id": "event_logo",
|
| 1699 |
+
"label": "Event Logo",
|
| 1700 |
+
"type": "logo",
|
| 1701 |
+
"required": true,
|
| 1702 |
+
"logo_role": "event"
|
| 1703 |
+
}
|
| 1704 |
+
]
|
| 1705 |
+
},
|
| 1706 |
+
{
|
| 1707 |
+
"template_key": "block_note",
|
| 1708 |
+
"friendly_name": "Block Note",
|
| 1709 |
+
"elements": [
|
| 1710 |
+
{
|
| 1711 |
+
"id": "company_logo",
|
| 1712 |
+
"label": "Company Logo",
|
| 1713 |
+
"type": "logo",
|
| 1714 |
+
"required": true,
|
| 1715 |
+
"logo_role": "company"
|
| 1716 |
+
},
|
| 1717 |
+
{
|
| 1718 |
+
"id": "event_logo",
|
| 1719 |
+
"label": "Event Logo",
|
| 1720 |
+
"type": "logo",
|
| 1721 |
+
"required": true,
|
| 1722 |
+
"logo_role": "event"
|
| 1723 |
+
},
|
| 1724 |
+
{
|
| 1725 |
+
"id": "event_name",
|
| 1726 |
+
"label": "Event Name",
|
| 1727 |
+
"type": "event_name",
|
| 1728 |
+
"required": true
|
| 1729 |
+
}
|
| 1730 |
+
]
|
| 1731 |
+
},
|
| 1732 |
+
{
|
| 1733 |
+
"template_key": "question_card",
|
| 1734 |
+
"friendly_name": "Question Card (A5 Double Face)",
|
| 1735 |
+
"elements": [
|
| 1736 |
+
{
|
| 1737 |
+
"id": "questions_header",
|
| 1738 |
+
"label": "Questions Header",
|
| 1739 |
+
"type": "text",
|
| 1740 |
+
"required": true,
|
| 1741 |
+
"expected_phrases": [
|
| 1742 |
+
"Questions"
|
| 1743 |
+
]
|
| 1744 |
+
},
|
| 1745 |
+
{
|
| 1746 |
+
"id": "writing_lines",
|
| 1747 |
+
"label": "Lines for Writing Questions",
|
| 1748 |
+
"type": "text_area",
|
| 1749 |
+
"required": true
|
| 1750 |
+
},
|
| 1751 |
+
{
|
| 1752 |
+
"id": "company_logo",
|
| 1753 |
+
"label": "Company Logo",
|
| 1754 |
+
"type": "logo",
|
| 1755 |
+
"required": true,
|
| 1756 |
+
"logo_role": "company"
|
| 1757 |
+
},
|
| 1758 |
+
{
|
| 1759 |
+
"id": "event_logo",
|
| 1760 |
+
"label": "Event Logo",
|
| 1761 |
+
"type": "logo",
|
| 1762 |
+
"required": true,
|
| 1763 |
+
"logo_role": "event"
|
| 1764 |
+
}
|
| 1765 |
]
|
| 1766 |
}
|
| 1767 |
]
|
| 1768 |
+
}
|
|
|
app/validator.py
CHANGED
|
@@ -10,8 +10,9 @@ import tempfile
|
|
| 10 |
import logging
|
| 11 |
import time
|
| 12 |
import shutil
|
|
|
|
| 13 |
from io import BytesIO
|
| 14 |
-
from typing import Dict, List, Optional, Tuple
|
| 15 |
from pathlib import Path
|
| 16 |
from dataclasses import dataclass, asdict
|
| 17 |
from datetime import datetime
|
|
@@ -585,6 +586,436 @@ class Validator:
|
|
| 585 |
self.client = load_llm_client()
|
| 586 |
# Use Claude Opus 4 which supports multimodal (images)
|
| 587 |
self.model = "claude-opus-4-20250514"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
|
| 589 |
def _generate_image_catalog(self, extracted_images: List[ExtractedImage]) -> str:
|
| 590 |
"""
|
|
@@ -1328,11 +1759,12 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1328 |
|
| 1329 |
return report
|
| 1330 |
|
| 1331 |
-
def validate_document(
|
| 1332 |
self,
|
| 1333 |
file_content: bytes,
|
| 1334 |
file_extension: str,
|
| 1335 |
-
template_key: str
|
|
|
|
| 1336 |
) -> Dict:
|
| 1337 |
"""
|
| 1338 |
Validate a document against a template using multimodal LLM.
|
|
@@ -1341,6 +1773,7 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1341 |
file_content: Binary content of the document file
|
| 1342 |
file_extension: File extension (e.g., '.pdf', '.docx', '.pptx')
|
| 1343 |
template_key: Template key to validate against
|
|
|
|
| 1344 |
|
| 1345 |
Returns:
|
| 1346 |
Validation report dictionary with status and element reports
|
|
@@ -1348,6 +1781,28 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1348 |
Raises:
|
| 1349 |
ValueError: If template not found, extraction fails, or validation fails
|
| 1350 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1351 |
# Load template
|
| 1352 |
template = get_template(template_key)
|
| 1353 |
if not template:
|
|
@@ -1480,6 +1935,7 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1480 |
logger.warning("No text extracted, but rendered image available. Proceeding with visual validation.")
|
| 1481 |
extracted_text = "[NO TEXT EXTRACTED - RELYING ON VISUAL VALIDATION]"
|
| 1482 |
|
|
|
|
| 1483 |
# Build multimodal prompt for rendered page
|
| 1484 |
logger.info("Building multimodal prompt for LLM...")
|
| 1485 |
if rendered_image_path:
|
|
@@ -1491,6 +1947,15 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1491 |
content = self._build_text_only_prompt(extracted_text, template)
|
| 1492 |
logger.info(f"Prompt contains {len(content)} content block(s) (text-only validation)")
|
| 1493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1494 |
# Call LLM API with fallback models (all support multimodal)
|
| 1495 |
models_to_try = [
|
| 1496 |
"claude-opus-4-20250514",
|
|
@@ -1576,6 +2041,9 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1576 |
else:
|
| 1577 |
raise ValueError("LLM API error: Unable to connect to any Claude model")
|
| 1578 |
|
|
|
|
|
|
|
|
|
|
| 1579 |
return validation_report
|
| 1580 |
|
| 1581 |
finally:
|
|
@@ -1629,58 +2097,53 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1629 |
|
| 1630 |
logger.info(f"Starting spell check for text ({len(document_text)} characters)")
|
| 1631 |
|
| 1632 |
-
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
CRITICAL RULES:
|
| 1637 |
-
1. IGNORE all proper nouns including:
|
| 1638 |
-
- Person names (English, Arabic, South Asian, and all other origins)
|
| 1639 |
-
- Place names (cities, countries, hospitals, venues, universities)
|
| 1640 |
-
- Organization names (companies, institutions, medical organizations)
|
| 1641 |
-
- Event names
|
| 1642 |
-
- Brand names
|
| 1643 |
-
- Medical titles: Dr., Prof., Sheikh, etc.
|
| 1644 |
-
|
| 1645 |
-
2. ONLY flag genuine errors:
|
| 1646 |
-
- Misspelled common words
|
| 1647 |
-
- Typos (swapped letters, missing letters, duplicated letters)
|
| 1648 |
-
- Wrong word usage in context
|
| 1649 |
-
- Basic grammar errors
|
| 1650 |
-
|
| 1651 |
-
3. For each error found, provide:
|
| 1652 |
-
- The incorrect word (exactly as it appears)
|
| 1653 |
-
- Surrounding context (about 20-30 characters before and after the word)
|
| 1654 |
-
- 2-3 suggestions for correction
|
| 1655 |
-
- Error type: "spelling", "grammar", or "typo"
|
| 1656 |
-
- Confidence level (0.0-1.0) - only flag errors you're confident about (>0.7)
|
| 1657 |
-
|
| 1658 |
-
4. SPECIAL CONSIDERATION FOR NAMES:
|
| 1659 |
-
- Arabic names and transliterations: محمد, أحمد, فاطمة, Muhammad, Ahmed, Fatima, etc.
|
| 1660 |
-
- Names following titles like Dr., Prof., Sheikh
|
| 1661 |
-
- Names in speaker lists, attendee lists, or after "presented by", "Speaker:", etc.
|
| 1662 |
-
- DO NOT flag unfamiliar names as errors
|
| 1663 |
-
|
| 1664 |
-
5. MEDICAL TERMINOLOGY:
|
| 1665 |
-
- Do not flag medical terms, abbreviations, or technical jargon
|
| 1666 |
-
- Accept CPD, DHA, RCP, and other medical organization acronyms
|
| 1667 |
-
|
| 1668 |
-
DOCUMENT TEXT TO CHECK:
|
| 1669 |
-
{document_text}
|
| 1670 |
|
| 1671 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1672 |
{{
|
| 1673 |
-
"total_errors":
|
| 1674 |
"errors": [
|
| 1675 |
{{
|
| 1676 |
-
"word": "
|
| 1677 |
-
"context": "
|
| 1678 |
-
"suggestions": ["suggestion1", "suggestion2"
|
| 1679 |
-
"error_type": "spelling|grammar|
|
| 1680 |
-
"confidence": 0.
|
| 1681 |
}}
|
| 1682 |
],
|
| 1683 |
-
"summary": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1684 |
}}
|
| 1685 |
|
| 1686 |
RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
@@ -1697,7 +2160,7 @@ RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
| 1697 |
messages=[
|
| 1698 |
{
|
| 1699 |
"role": "user",
|
| 1700 |
-
"content": [{"type": "text", "text":
|
| 1701 |
}
|
| 1702 |
]
|
| 1703 |
)
|
|
|
|
| 10 |
import logging
|
| 11 |
import time
|
| 12 |
import shutil
|
| 13 |
+
import io
|
| 14 |
from io import BytesIO
|
| 15 |
+
from typing import Dict, List, Optional, Tuple, Any
|
| 16 |
from pathlib import Path
|
| 17 |
from dataclasses import dataclass, asdict
|
| 18 |
from datetime import datetime
|
|
|
|
| 586 |
self.client = load_llm_client()
|
| 587 |
# Use Claude Opus 4 which supports multimodal (images)
|
| 588 |
self.model = "claude-opus-4-20250514"
|
| 589 |
+
|
| 590 |
+
async def check_links(self, links: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 591 |
+
"""
|
| 592 |
+
Check health of extracted links using HTTP HEAD/GET requests.
|
| 593 |
+
"""
|
| 594 |
+
import aiohttp
|
| 595 |
+
import asyncio
|
| 596 |
+
|
| 597 |
+
results = []
|
| 598 |
+
if not links:
|
| 599 |
+
return results
|
| 600 |
+
|
| 601 |
+
# Add headers to avoid being blocked as a bot
|
| 602 |
+
headers = {
|
| 603 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
# Increase timeout to 10 seconds for slower sites
|
| 607 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10), headers=headers) as session:
|
| 608 |
+
for link in links:
|
| 609 |
+
url = link["url"]
|
| 610 |
+
# Handle www without protocol
|
| 611 |
+
check_url = url
|
| 612 |
+
if url.startswith("www."):
|
| 613 |
+
check_url = "https://" + url
|
| 614 |
+
|
| 615 |
+
status = "unknown"
|
| 616 |
+
message = ""
|
| 617 |
+
status_code = 0
|
| 618 |
+
|
| 619 |
+
try:
|
| 620 |
+
if check_url.startswith("mailto:"):
|
| 621 |
+
status = "valid" # Assume mailto is valid format
|
| 622 |
+
message = "Email link"
|
| 623 |
+
status_code = 200
|
| 624 |
+
else:
|
| 625 |
+
try:
|
| 626 |
+
# Try GET directly (skip HEAD since many sites block it)
|
| 627 |
+
async with session.get(check_url, allow_redirects=True, ssl=False) as response:
|
| 628 |
+
status_code = response.status
|
| 629 |
+
if 200 <= status_code < 400:
|
| 630 |
+
status = "valid"
|
| 631 |
+
message = "OK"
|
| 632 |
+
else:
|
| 633 |
+
status = "broken"
|
| 634 |
+
message = f"HTTP {status_code}"
|
| 635 |
+
except aiohttp.ClientError as e:
|
| 636 |
+
# More specific error message
|
| 637 |
+
status = "broken"
|
| 638 |
+
message = f"Connection error: {type(e).__name__}"
|
| 639 |
+
status_code = 0
|
| 640 |
+
except asyncio.TimeoutError:
|
| 641 |
+
status = "broken"
|
| 642 |
+
message = "Timeout (>10s)"
|
| 643 |
+
status_code = 408
|
| 644 |
+
except Exception as e:
|
| 645 |
+
status = "broken"
|
| 646 |
+
message = f"Error: {type(e).__name__}"
|
| 647 |
+
status_code = 0
|
| 648 |
+
|
| 649 |
+
results.append({
|
| 650 |
+
"url": url,
|
| 651 |
+
"status": status,
|
| 652 |
+
"status_code": status_code,
|
| 653 |
+
"message": message,
|
| 654 |
+
"page": str(link.get("page", "Unknown"))
|
| 655 |
+
})
|
| 656 |
+
|
| 657 |
+
return results
|
| 658 |
+
|
| 659 |
+
async def compare_documents(
|
| 660 |
+
self,
|
| 661 |
+
file1_content: bytes,
|
| 662 |
+
file1_extension: str,
|
| 663 |
+
file1_name: str,
|
| 664 |
+
file2_content: bytes,
|
| 665 |
+
file2_extension: str,
|
| 666 |
+
file2_name: str
|
| 667 |
+
) -> Dict[str, Any]:
|
| 668 |
+
"""
|
| 669 |
+
Compare two document versions using LLM to identify semantic changes.
|
| 670 |
+
|
| 671 |
+
Args:
|
| 672 |
+
file1_content: Binary content of first document
|
| 673 |
+
file1_extension: File extension of first document
|
| 674 |
+
file1_name: Filename of first document
|
| 675 |
+
file2_content: Binary content of second document
|
| 676 |
+
file2_extension: File extension of second document
|
| 677 |
+
file2_name: Filename of second document
|
| 678 |
+
|
| 679 |
+
Returns:
|
| 680 |
+
Dictionary with comparison results including summary and detailed changes
|
| 681 |
+
"""
|
| 682 |
+
logger.info(f"Starting comparison: {file1_name} vs {file2_name}")
|
| 683 |
+
|
| 684 |
+
# Extract text from both documents
|
| 685 |
+
text1 = extract_document_text(file1_content, file1_extension)
|
| 686 |
+
text2 = extract_document_text(file2_content, file2_extension)
|
| 687 |
+
|
| 688 |
+
logger.info(f"Extracted text - File 1: {len(text1)} chars, File 2: {len(text2)} chars")
|
| 689 |
+
|
| 690 |
+
if not text1 and not text2:
|
| 691 |
+
raise ValueError("Both documents appear to be empty or contain no extractable text")
|
| 692 |
+
|
| 693 |
+
# Build LLM prompt for comparison
|
| 694 |
+
comparison_prompt = f"""You are comparing two versions of a document to identify what changed.
|
| 695 |
+
|
| 696 |
+
DOCUMENT 1 ({file1_name}):
|
| 697 |
+
{text1[:10000]} # Limit to avoid token limits
|
| 698 |
+
|
| 699 |
+
DOCUMENT 2 ({file2_name}):
|
| 700 |
+
{text2[:10000]}
|
| 701 |
+
|
| 702 |
+
Please analyze the differences between these two documents and provide:
|
| 703 |
+
|
| 704 |
+
1. A natural language summary of the main changes (2-3 sentences)
|
| 705 |
+
2. A detailed list of specific changes
|
| 706 |
+
|
| 707 |
+
Format your response as a JSON object with this structure:
|
| 708 |
+
{{
|
| 709 |
+
"summary": "Brief summary of changes...",
|
| 710 |
+
"changes": [
|
| 711 |
+
{{
|
| 712 |
+
"type": "addition|deletion|modification",
|
| 713 |
+
"section": "Optional section name where change occurred",
|
| 714 |
+
"description": "Description of the change"
|
| 715 |
+
}}
|
| 716 |
+
]
|
| 717 |
+
}}
|
| 718 |
+
|
| 719 |
+
Focus on:
|
| 720 |
+
- Content additions or deletions
|
| 721 |
+
- Text modifications
|
| 722 |
+
- Structural changes (headings, lists, tables)
|
| 723 |
+
- Significant formatting changes
|
| 724 |
+
|
| 725 |
+
If the documents are identical, return an empty changes array.
|
| 726 |
+
"""
|
| 727 |
+
|
| 728 |
+
try:
|
| 729 |
+
# Call LLM API
|
| 730 |
+
logger.info("Calling LLM for document comparison...")
|
| 731 |
+
|
| 732 |
+
message = self.client.messages.create(
|
| 733 |
+
model="claude-opus-4-20250514",
|
| 734 |
+
max_tokens=4096,
|
| 735 |
+
temperature=0.1,
|
| 736 |
+
messages=[
|
| 737 |
+
{
|
| 738 |
+
"role": "user",
|
| 739 |
+
"content": comparison_prompt
|
| 740 |
+
}
|
| 741 |
+
]
|
| 742 |
+
)
|
| 743 |
+
|
| 744 |
+
response_text = message.content[0].text if message.content else ""
|
| 745 |
+
logger.info(f"Received comparison response ({len(response_text)} chars)")
|
| 746 |
+
|
| 747 |
+
# Parse JSON response
|
| 748 |
+
import json
|
| 749 |
+
comparison_data = json.loads(response_text)
|
| 750 |
+
|
| 751 |
+
logger.info(f"Comparison complete: {len(comparison_data.get('changes', []))} changes detected")
|
| 752 |
+
|
| 753 |
+
return comparison_data
|
| 754 |
+
|
| 755 |
+
except Exception as e:
|
| 756 |
+
logger.error(f"Comparison failed: {str(e)}", exc_info=True)
|
| 757 |
+
raise ValueError(f"Failed to compare documents: {str(e)}")
|
| 758 |
+
|
| 759 |
+
async def bulk_validate_certificates(
|
| 760 |
+
self,
|
| 761 |
+
excel_content: bytes,
|
| 762 |
+
name_column: str,
|
| 763 |
+
certificate_data: List[Tuple[str, bytes, str]]
|
| 764 |
+
) -> Dict[str, Any]:
|
| 765 |
+
"""
|
| 766 |
+
Validate multiple certificates against Excel name list with fuzzy matching.
|
| 767 |
+
|
| 768 |
+
Args:
|
| 769 |
+
excel_content: Binary content of Excel file
|
| 770 |
+
name_column: Column name containing names
|
| 771 |
+
certificate_data: List of (filename, content, extension) tuples
|
| 772 |
+
|
| 773 |
+
Returns:
|
| 774 |
+
Dictionary with validation results including exact/fuzzy matches
|
| 775 |
+
"""
|
| 776 |
+
logger.info(f"Starting bulk validation: {len(certificate_data)} certificates")
|
| 777 |
+
|
| 778 |
+
try:
|
| 779 |
+
import openpyxl
|
| 780 |
+
from io import BytesIO
|
| 781 |
+
from difflib import SequenceMatcher
|
| 782 |
+
|
| 783 |
+
# Parse Excel and extract names
|
| 784 |
+
wb = openpyxl.load_workbook(BytesIO(excel_content))
|
| 785 |
+
ws = wb.active
|
| 786 |
+
|
| 787 |
+
# Find column index
|
| 788 |
+
headers = [str(cell.value) for cell in ws[1] if cell.value]
|
| 789 |
+
if name_column not in headers:
|
| 790 |
+
raise ValueError(f"Column '{name_column}' not found in Excel file")
|
| 791 |
+
|
| 792 |
+
col_idx = headers.index(name_column) + 1
|
| 793 |
+
|
| 794 |
+
# Extract names from Excel (skip header row)
|
| 795 |
+
excel_names = []
|
| 796 |
+
for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
|
| 797 |
+
if row[0].value:
|
| 798 |
+
excel_names.append(str(row[0].value).strip())
|
| 799 |
+
|
| 800 |
+
logger.info(f"Extracted {len(excel_names)} names from Excel")
|
| 801 |
+
|
| 802 |
+
# Extract names from certificates (parallel processing)
|
| 803 |
+
cert_names = {}
|
| 804 |
+
for filename, content, ext in certificate_data:
|
| 805 |
+
try:
|
| 806 |
+
text = extract_document_text(content, ext)
|
| 807 |
+
# Store extracted text for this certificate
|
| 808 |
+
cert_names[filename] = text
|
| 809 |
+
except Exception as e:
|
| 810 |
+
logger.warning(f"Failed to extract from {filename}: {str(e)}")
|
| 811 |
+
cert_names[filename] = ""
|
| 812 |
+
|
| 813 |
+
logger.info(f"Extracted text from {len(cert_names)} certificates")
|
| 814 |
+
|
| 815 |
+
# Match names
|
| 816 |
+
results = {
|
| 817 |
+
"total_names": len(excel_names),
|
| 818 |
+
"total_certificates": len(certificate_data),
|
| 819 |
+
"exact_matches": 0,
|
| 820 |
+
"fuzzy_matches": 0,
|
| 821 |
+
"missing": 0,
|
| 822 |
+
"extras": 0,
|
| 823 |
+
"details": []
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
matched_certs = set()
|
| 827 |
+
|
| 828 |
+
# Check each Excel name against certificates
|
| 829 |
+
for name in excel_names:
|
| 830 |
+
found = False
|
| 831 |
+
best_match = None
|
| 832 |
+
best_similarity = 0
|
| 833 |
+
|
| 834 |
+
for cert_file, cert_text in cert_names.items():
|
| 835 |
+
# Exact match
|
| 836 |
+
if name.lower() in cert_text.lower():
|
| 837 |
+
results["exact_matches"] += 1
|
| 838 |
+
results["details"].append({
|
| 839 |
+
"name": name,
|
| 840 |
+
"status": "exact_match",
|
| 841 |
+
"certificate_file": cert_file,
|
| 842 |
+
"similarity": 100
|
| 843 |
+
})
|
| 844 |
+
matched_certs.add(cert_file)
|
| 845 |
+
found = True
|
| 846 |
+
break
|
| 847 |
+
|
| 848 |
+
# Fuzzy match
|
| 849 |
+
similarity = SequenceMatcher(None, name.lower(), cert_text.lower()).ratio() * 100
|
| 850 |
+
if similarity >= 90 and similarity > best_similarity:
|
| 851 |
+
best_similarity = similarity
|
| 852 |
+
best_match = cert_file
|
| 853 |
+
|
| 854 |
+
if not found:
|
| 855 |
+
if best_match and best_similarity >= 90:
|
| 856 |
+
# Fuzzy match found
|
| 857 |
+
results["fuzzy_matches"] += 1
|
| 858 |
+
results["details"].append({
|
| 859 |
+
"name": name,
|
| 860 |
+
"status": "fuzzy_match",
|
| 861 |
+
"certificate_file": best_match,
|
| 862 |
+
"similarity": int(best_similarity)
|
| 863 |
+
})
|
| 864 |
+
matched_certs.add(best_match)
|
| 865 |
+
else:
|
| 866 |
+
# Missing
|
| 867 |
+
results["missing"] += 1
|
| 868 |
+
results["details"].append({
|
| 869 |
+
"name": name,
|
| 870 |
+
"status": "missing",
|
| 871 |
+
"certificate_file": None,
|
| 872 |
+
"similarity": None
|
| 873 |
+
})
|
| 874 |
+
|
| 875 |
+
# Find extra certificates (not matched to any Excel name)
|
| 876 |
+
for cert_file in cert_names.keys():
|
| 877 |
+
if cert_file not in matched_certs:
|
| 878 |
+
results["extras"] += 1
|
| 879 |
+
results["details"].append({
|
| 880 |
+
"name": f"[Certificate: {cert_file}]",
|
| 881 |
+
"status": "extra",
|
| 882 |
+
"certificate_file": cert_file,
|
| 883 |
+
"similarity": None
|
| 884 |
+
})
|
| 885 |
+
|
| 886 |
+
logger.info(f"Bulk validation complete: {results['exact_matches']} exact, "
|
| 887 |
+
f"{results['fuzzy_matches']} fuzzy, {results['missing']} missing, "
|
| 888 |
+
f"{results['extras']} extra")
|
| 889 |
+
|
| 890 |
+
return results
|
| 891 |
+
|
| 892 |
+
except Exception as e:
|
| 893 |
+
logger.error(f"Bulk validation failed: {str(e)}", exc_info=True)
|
| 894 |
+
raise ValueError(f"Failed to validate certificates: {str(e)}")
|
| 895 |
+
|
| 896 |
+
def extract_links(self, file_content: bytes, file_extension: str) -> List[Dict[str, Any]]:
|
| 897 |
+
"""
|
| 898 |
+
Extract links from PDF, DOCX, or PPTX files.
|
| 899 |
+
"""
|
| 900 |
+
links = []
|
| 901 |
+
logger.info(f"Extracting links from {file_extension} document (size: {len(file_content)} bytes)")
|
| 902 |
+
|
| 903 |
+
try:
|
| 904 |
+
if file_extension == ".pdf":
|
| 905 |
+
with fitz.open(stream=file_content, filetype="pdf") as doc:
|
| 906 |
+
logger.info(f"PDF page count: {len(doc)}")
|
| 907 |
+
for page_num, page in enumerate(doc):
|
| 908 |
+
page_links = page.get_links()
|
| 909 |
+
logger.info(f"Page {page_num+1} has {len(page_links)} link objects")
|
| 910 |
+
for link in page_links:
|
| 911 |
+
if "uri" in link:
|
| 912 |
+
logger.info(f" Found PDF URI: {link['uri']}")
|
| 913 |
+
links.append({
|
| 914 |
+
"url": link["uri"],
|
| 915 |
+
"page": page_num + 1,
|
| 916 |
+
"source": "page_link"
|
| 917 |
+
})
|
| 918 |
+
|
| 919 |
+
elif file_extension == ".docx":
|
| 920 |
+
# For DOCX, we need to inspect the relationship files in the zip
|
| 921 |
+
from zipfile import ZipFile
|
| 922 |
+
from lxml import etree
|
| 923 |
+
|
| 924 |
+
logger.info("Processing DOCX for links...")
|
| 925 |
+
with io.BytesIO(file_content) as docx_file:
|
| 926 |
+
with ZipFile(docx_file) as zip_ref:
|
| 927 |
+
# List all files for debugging
|
| 928 |
+
# logger.info(f"Files in DOCX: {zip_ref.namelist()}")
|
| 929 |
+
|
| 930 |
+
# Find all relationship files
|
| 931 |
+
rel_files = [f for f in zip_ref.namelist() if f.endswith(".rels")]
|
| 932 |
+
logger.info(f"Found {len(rel_files)} relationship files: {rel_files}")
|
| 933 |
+
|
| 934 |
+
for rel_file in rel_files:
|
| 935 |
+
try:
|
| 936 |
+
with zip_ref.open(rel_file) as f:
|
| 937 |
+
tree = etree.parse(f)
|
| 938 |
+
root = tree.getroot()
|
| 939 |
+
namespaces = {'rel': 'http://schemas.openxmlformats.org/package/2006/relationships'}
|
| 940 |
+
|
| 941 |
+
rels = root.findall(".//rel:Relationship", namespaces)
|
| 942 |
+
logger.info(f" Scanning {rel_file}: found {len(rels)} relationships")
|
| 943 |
+
|
| 944 |
+
for rel in rels:
|
| 945 |
+
target = rel.get("Target")
|
| 946 |
+
type_attr = rel.get("Type")
|
| 947 |
+
|
| 948 |
+
if type_attr and "hyperlink" in type_attr and target:
|
| 949 |
+
logger.info(f" Found DOCX Hyperlink: {target}")
|
| 950 |
+
links.append({
|
| 951 |
+
"url": target,
|
| 952 |
+
"page": "Unknown", # DOCX doesn't have fixed pages
|
| 953 |
+
"source": "document_link"
|
| 954 |
+
})
|
| 955 |
+
except Exception as e:
|
| 956 |
+
logger.error(f"Error parsing {rel_file}: {e}")
|
| 957 |
+
continue
|
| 958 |
+
|
| 959 |
+
elif file_extension == ".pptx":
|
| 960 |
+
from pptx import Presentation
|
| 961 |
+
logger.info("Processing PPTX for links...")
|
| 962 |
+
with io.BytesIO(file_content) as ppt_file:
|
| 963 |
+
prs = Presentation(ppt_file)
|
| 964 |
+
for slide_num, slide in enumerate(prs.slides):
|
| 965 |
+
logger.info(f"Scanning Slide {slide_num+1} with {len(slide.shapes)} shapes")
|
| 966 |
+
for shape in slide.shapes:
|
| 967 |
+
# Check shape click action
|
| 968 |
+
try:
|
| 969 |
+
if shape.click_action and shape.click_action.hyperlink and shape.click_action.hyperlink.address:
|
| 970 |
+
url = shape.click_action.hyperlink.address
|
| 971 |
+
logger.info(f" Found PPTX Shape Link: {url}")
|
| 972 |
+
links.append({
|
| 973 |
+
"url": url,
|
| 974 |
+
"page": f"Slide {slide_num + 1}",
|
| 975 |
+
"source": "shape_link"
|
| 976 |
+
})
|
| 977 |
+
except AttributeError:
|
| 978 |
+
pass
|
| 979 |
+
|
| 980 |
+
# Check text runs
|
| 981 |
+
if hasattr(shape, "text_frame"):
|
| 982 |
+
try:
|
| 983 |
+
for paragraph in shape.text_frame.paragraphs:
|
| 984 |
+
for run in paragraph.runs:
|
| 985 |
+
if run.hyperlink and run.hyperlink.address:
|
| 986 |
+
url = run.hyperlink.address
|
| 987 |
+
logger.info(f" Found PPTX Text Link: {url}")
|
| 988 |
+
links.append({
|
| 989 |
+
"url": url,
|
| 990 |
+
"page": f"Slide {slide_num + 1}",
|
| 991 |
+
"source": "text_link"
|
| 992 |
+
})
|
| 993 |
+
except AttributeError:
|
| 994 |
+
pass
|
| 995 |
+
|
| 996 |
+
except Exception as e:
|
| 997 |
+
logger.error(f"Error extracting links: {str(e)}", exc_info=True)
|
| 998 |
+
|
| 999 |
+
# Deduplicate links
|
| 1000 |
+
unique_links = []
|
| 1001 |
+
seen_urls = set()
|
| 1002 |
+
logger.info(f"Total raw links found: {len(links)}")
|
| 1003 |
+
|
| 1004 |
+
for link in links:
|
| 1005 |
+
url = link["url"].strip()
|
| 1006 |
+
# Relaxed filtering logic for debugging: accept everything that looks like a potential link
|
| 1007 |
+
# We'll filter strictly later if needed, but for now we want to see what we rejected
|
| 1008 |
+
is_valid_format = (url.startswith("http") or url.startswith("mailto:") or url.startswith("www."))
|
| 1009 |
+
|
| 1010 |
+
if not is_valid_format:
|
| 1011 |
+
logger.warning(f"Rejected link format: '{url}'")
|
| 1012 |
+
|
| 1013 |
+
if url and url not in seen_urls and is_valid_format:
|
| 1014 |
+
seen_urls.add(url)
|
| 1015 |
+
unique_links.append(link)
|
| 1016 |
+
|
| 1017 |
+
logger.info(f"Unique valid links returned: {len(unique_links)}")
|
| 1018 |
+
return unique_links
|
| 1019 |
|
| 1020 |
def _generate_image_catalog(self, extracted_images: List[ExtractedImage]) -> str:
|
| 1021 |
"""
|
|
|
|
| 1759 |
|
| 1760 |
return report
|
| 1761 |
|
| 1762 |
+
async def validate_document(
|
| 1763 |
self,
|
| 1764 |
file_content: bytes,
|
| 1765 |
file_extension: str,
|
| 1766 |
+
template_key: str,
|
| 1767 |
+
custom_prompt: Optional[str] = None
|
| 1768 |
) -> Dict:
|
| 1769 |
"""
|
| 1770 |
Validate a document against a template using multimodal LLM.
|
|
|
|
| 1773 |
file_content: Binary content of the document file
|
| 1774 |
file_extension: File extension (e.g., '.pdf', '.docx', '.pptx')
|
| 1775 |
template_key: Template key to validate against
|
| 1776 |
+
custom_prompt: Optional custom instructions to adapt validation
|
| 1777 |
|
| 1778 |
Returns:
|
| 1779 |
Validation report dictionary with status and element reports
|
|
|
|
| 1781 |
Raises:
|
| 1782 |
ValueError: If template not found, extraction fails, or validation fails
|
| 1783 |
"""
|
| 1784 |
+
logger.info(f"Starting validation for {file_extension} document against template {template_key}")
|
| 1785 |
+
|
| 1786 |
+
# 1. Extract Links & Check Health (Async)
|
| 1787 |
+
logger.info("======================================")
|
| 1788 |
+
logger.info("STARTING LINK VALIDATION")
|
| 1789 |
+
logger.info("======================================")
|
| 1790 |
+
logger.info("Extracting and checking links...")
|
| 1791 |
+
try:
|
| 1792 |
+
extracted_links = self.extract_links(file_content, file_extension)
|
| 1793 |
+
logger.info(f"✓ extract_links returned {len(extracted_links)} links")
|
| 1794 |
+
if extracted_links:
|
| 1795 |
+
logger.info(f" Links: {[link.get('url') for link in extracted_links]}")
|
| 1796 |
+
|
| 1797 |
+
link_validation_results = await self.check_links(extracted_links)
|
| 1798 |
+
logger.info(f"✓ check_links returned {len(link_validation_results)} results")
|
| 1799 |
+
except Exception as e:
|
| 1800 |
+
logger.error(f"❌ Link validation failed with exception: {e}", exc_info=True)
|
| 1801 |
+
link_validation_results = []
|
| 1802 |
+
|
| 1803 |
+
logger.info(f"Final link_validation_results count: {len(link_validation_results)}")
|
| 1804 |
+
logger.info("======================================")
|
| 1805 |
+
|
| 1806 |
# Load template
|
| 1807 |
template = get_template(template_key)
|
| 1808 |
if not template:
|
|
|
|
| 1935 |
logger.warning("No text extracted, but rendered image available. Proceeding with visual validation.")
|
| 1936 |
extracted_text = "[NO TEXT EXTRACTED - RELYING ON VISUAL VALIDATION]"
|
| 1937 |
|
| 1938 |
+
|
| 1939 |
# Build multimodal prompt for rendered page
|
| 1940 |
logger.info("Building multimodal prompt for LLM...")
|
| 1941 |
if rendered_image_path:
|
|
|
|
| 1947 |
content = self._build_text_only_prompt(extracted_text, template)
|
| 1948 |
logger.info(f"Prompt contains {len(content)} content block(s) (text-only validation)")
|
| 1949 |
|
| 1950 |
+
# Append custom instructions if provided
|
| 1951 |
+
if custom_prompt and custom_prompt.strip():
|
| 1952 |
+
custom_instruction_block = {
|
| 1953 |
+
"type": "text",
|
| 1954 |
+
"text": f"\n\n ADDITIONAL USER INSTRUCTIONS:\n{custom_prompt.strip()}\n\nPlease incorporate these additional instructions into your validation process."
|
| 1955 |
+
}
|
| 1956 |
+
content.append(custom_instruction_block)
|
| 1957 |
+
logger.info(f"Added custom instructions to prompt ({len(custom_prompt)} characters)")
|
| 1958 |
+
|
| 1959 |
# Call LLM API with fallback models (all support multimodal)
|
| 1960 |
models_to_try = [
|
| 1961 |
"claude-opus-4-20250514",
|
|
|
|
| 2041 |
else:
|
| 2042 |
raise ValueError("LLM API error: Unable to connect to any Claude model")
|
| 2043 |
|
| 2044 |
+
# Add link report to result
|
| 2045 |
+
validation_report["link_report"] = link_validation_results
|
| 2046 |
+
|
| 2047 |
return validation_report
|
| 2048 |
|
| 2049 |
finally:
|
|
|
|
| 2097 |
|
| 2098 |
logger.info(f"Starting spell check for text ({len(document_text)} characters)")
|
| 2099 |
|
| 2100 |
+
|
| 2101 |
+
# Prepare prompt for quality checking (spelling, grammar, formatting)
|
| 2102 |
+
prompt = f"""
|
| 2103 |
+
Analyze the following text from a medical document for spelling, grammar, and formatting consistency issues.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2104 |
|
| 2105 |
+
TEXT TO ANALYZE:
|
| 2106 |
+
---
|
| 2107 |
+
{document_text}
|
| 2108 |
+
---
|
| 2109 |
+
|
| 2110 |
+
INSTRUCTIONS:
|
| 2111 |
+
1. **Spelling & Arabic Support**:
|
| 2112 |
+
- Check both English and Arabic text for spelling errors.
|
| 2113 |
+
- IGNORE proper names (including common Arabic names like Mohammed, Ahmed, etc.), locations, and medical terminology.
|
| 2114 |
+
- IGNORE brand names or specialized abbreviations.
|
| 2115 |
+
|
| 2116 |
+
2. **Grammar**:
|
| 2117 |
+
- Identify grammatical errors, awkward phrasing, or punctuation issues.
|
| 2118 |
+
- Ensure the tone remains professional.
|
| 2119 |
+
|
| 2120 |
+
3. **Formatting Consistency (CRITICAL)**:
|
| 2121 |
+
- **AM/PM Consistency**: Strictly only uppercase "AM" and "PM" are permitted.
|
| 2122 |
+
- Flag ANY variation such as "Am", "am", "aM", "Pm", "pm", "pM" as a "formatting" error.
|
| 2123 |
+
- Example: if you see "10:00am" or "10:00 Am", flag it and suggest "10:00 AM".
|
| 2124 |
+
- **Date Consistency**: Check for inconsistent date formats (e.g., mixing MM/DD/YYYY and DD.MM.YYYY).
|
| 2125 |
+
|
| 2126 |
+
4. **Output Format**:
|
| 2127 |
+
Return your findings STRICTLY as a JSON object with this structure:
|
| 2128 |
{{
|
| 2129 |
+
"total_errors": number,
|
| 2130 |
"errors": [
|
| 2131 |
{{
|
| 2132 |
+
"word": "the specific word or phrase with the issue",
|
| 2133 |
+
"context": "a short snippet of the surrounding text (about 5 words before and after)",
|
| 2134 |
+
"suggestions": ["suggestion1", "suggestion2"],
|
| 2135 |
+
"error_type": "spelling" | "grammar" | "formatting",
|
| 2136 |
+
"confidence": 0.0 to 1.0
|
| 2137 |
}}
|
| 2138 |
],
|
| 2139 |
+
"summary": "a brief 1-2 sentence overview of the issues found"
|
| 2140 |
+
}}
|
| 2141 |
+
|
| 2142 |
+
If no errors are found, return exactly:
|
| 2143 |
+
{{
|
| 2144 |
+
"total_errors": 0,
|
| 2145 |
+
"errors": [],
|
| 2146 |
+
"summary": "No spelling, grammar, or formatting issues found."
|
| 2147 |
}}
|
| 2148 |
|
| 2149 |
RESPOND WITH JSON ONLY - NO ADDITIONAL TEXT OR MARKDOWN.
|
|
|
|
| 2160 |
messages=[
|
| 2161 |
{
|
| 2162 |
"role": "user",
|
| 2163 |
+
"content": [{"type": "text", "text": prompt}]
|
| 2164 |
}
|
| 2165 |
]
|
| 2166 |
)
|
requirements.txt
CHANGED
|
@@ -10,3 +10,8 @@ python-dotenv>=1.0.1
|
|
| 10 |
Pillow>=10.0.0
|
| 11 |
pytesseract>=0.3.10
|
| 12 |
pdf2image>=1.17.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Pillow>=10.0.0
|
| 11 |
pytesseract>=0.3.10
|
| 12 |
pdf2image>=1.17.0
|
| 13 |
+
aiohttp>=3.9.1
|
| 14 |
+
python-docx>=1.1.2
|
| 15 |
+
python-pptx>=0.6.23
|
| 16 |
+
lxml>=4.9.2
|
| 17 |
+
openpyxl>=3.1.0
|