saifisvibin commited on
Commit
6ec2d12
·
1 Parent(s): e247414

Add document comparison, bulk validation, and projects features

Browse files
Files changed (7) hide show
  1. .gitignore +15 -0
  2. app/database.py +190 -0
  3. app/main.py +423 -6
  4. app/static/index.html +855 -44
  5. app/templates.json +1669 -196
  6. app/validator.py +513 -50
  7. 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
- .checkbox-wrapper {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  display: flex;
367
- align-items: center;
368
- gap: 10px;
369
- padding: 12px;
370
- background: #f8f9fa;
371
- border-radius: 8px;
372
- margin-bottom: 20px;
373
  }
374
 
375
- .checkbox-wrapper input[type="checkbox"] {
376
- width: 20px;
377
- height: 20px;
 
 
 
 
 
378
  cursor: pointer;
 
 
379
  }
380
 
381
- .checkbox-wrapper label {
382
- margin: 0;
383
- cursor: pointer;
384
- font-weight: 500;
385
- user-select: none;
 
 
 
 
 
 
 
 
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:</label>
407
- <select id="templateSelect" name="template" required>
 
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="checkbox-wrapper">
419
- <input type="checkbox" id="spellCheckToggle" name="spell_check">
420
- <label for="spellCheckToggle">✓ Check spelling in document (ignores proper names including Arabic
421
- names)</label>
 
 
 
 
422
  </div>
423
 
424
- <button type="submit" class="btn" id="submitBtn">Validate Document</button>
 
 
 
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
- <div class="loading" id="loading">
437
- <div class="spinner"></div>
438
- <p>Validating document...</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  </div>
440
 
441
- <div class="error" id="error"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
 
443
- <div class="results" id="results">
444
- <div class="status" id="status"></div>
445
- <div class="summary" id="summary"></div>
446
- <ul class="elements-list" id="elementsList"></ul>
 
 
 
 
447
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  </div>
449
 
450
  <script>
@@ -481,17 +724,83 @@
481
  }
482
  });
483
 
484
- // Handle form submission
485
- document.getElementById('validationForm').addEventListener('submit', async function (e) {
486
- e.preventDefault();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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('submitBtn').disabled = true;
 
516
 
517
  try {
518
  const formData = new FormData();
519
  formData.append('file', file);
520
 
521
- // Build URL with spell check parameter
522
- const url = `/validate?template_key=${encodeURIComponent(templateKey)}&check_spelling=${spellCheckEnabled}`;
 
 
 
 
 
 
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('submitBtn').disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = `📝 Spelling Check<span style="font-size: 14px; color: #666; font-weight: normal; margin-left: auto;">${spellCheck.summary}</span>`;
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
- { "id": "certificate_title", "label": "Certificate Title", "type": "text", "required": true, "expected_phrases": ["Certificate of Appreciation"] },
8
- { "id": "recipient_name", "label": "Recipient Name", "type": "person_name", "required": true, "placeholder_examples": ["Dr << Name >>"] },
9
- { "id": "purpose_text", "label": "Purpose Text", "type": "text", "required": true, "expected_phrases": ["to express our gratitude and appreciation for participating as a Speaker/Chairperson"] },
10
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true, "placeholder_examples": ["<<Event Name>>"] },
11
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true, "placeholder_examples": ["<<Event Date>>"] },
12
- { "id": "venue", "label": "Venue", "type": "venue", "required": true, "placeholder_examples": ["<<Venue>>"] },
13
- { "id": "event_chairperson_signature", "label": "Event Chairperson Signature", "type": "signature_block", "required": true, "expected_phrases": ["Event Chairperson", "<<Signature>>"] },
14
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true, "logo_role": "event" },
15
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true, "logo_role": "company" }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  ]
17
  },
18
  {
19
  "template_key": "certificate_attendance",
20
  "friendly_name": "Certificate of Attendance",
21
  "elements": [
22
- { "id": "certificate_title", "label": "Certificate Title", "type": "text", "required": true, "expected_phrases": ["Certificate of Attendance"] },
23
- { "id": "recipient_name", "label": "Recipient Name", "type": "person_name", "required": true, "placeholder_examples": ["Dr. << Name >>"] },
24
- { "id": "confirmation_text", "label": "Confirmation Text", "type": "text", "required": true, "expected_phrases": ["This certificate confirms that", "has attended the meeting titled"] },
25
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
26
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
27
- { "id": "event_chairperson_signature", "label": "Event Chairperson Signature", "type": "signature_block", "required": true },
28
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true, "logo_role": "event" },
29
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true, "logo_role": "company" }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  ]
31
  },
32
  {
33
  "template_key": "cpd_certificate_accreditation_generic",
34
  "friendly_name": "CPD Certificate of Accreditation (Generic)",
35
  "elements": [
36
- { "id": "certificate_title", "label": "Certificate Title", "type": "text", "required": true, "expected_phrases": ["Certificate of Accreditation"] },
37
- { "id": "recipient_name", "label": "Recipient Full Name", "type": "person_name", "required": true, "placeholder_examples": ["<<Full Name>>"] },
38
- { "id": "accreditation_code", "label": "Accreditation Code Number", "type": "code", "required": true },
39
- { "id": "cpd_points", "label": "CPD Credit Points", "type": "number_or_text", "required": true },
40
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
41
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
42
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
43
- { "id": "confirmation_text", "label": "Attendance Confirmation Text", "type": "text", "required": true, "expected_phrases": ["This certificate confirms that", "Has attended the event"] }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ]
45
  },
46
  {
47
  "template_key": "html_email_reminder",
48
  "friendly_name": "HTML Email Reminder",
49
  "elements": [
50
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true },
51
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
52
- { "id": "greeting", "label": "Greeting with First Name Merge Tag", "type": "text", "required": true, "expected_phrases": ["Dear Dr.", "*|FNAME|*"] },
53
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
54
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
55
- { "id": "time", "label": "Time Range", "type": "time_range", "required": true },
56
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
57
- { "id": "chairpersons_block", "label": "Chairpersons Block", "type": "section", "required": true, "expected_subfields": ["Pic", "Name", "Bio"] },
58
- { "id": "speakers_block", "label": "Speakers Block", "type": "section", "required": true, "expected_subfields": ["Pic", "Name", "Bio"] },
59
- { "id": "agenda_table", "label": "Agenda Table", "type": "table", "required": true, "expected_columns": ["Time", "Topic", "Faculty"] },
60
- { "id": "cta_register_join", "label": "CTA to Register/Join", "type": "cta", "required": true, "expected_phrases": ["Click here to register", "Click here to join"] },
61
- { "id": "disclaimer", "label": "Disclaimer", "type": "text", "required": true },
62
- { "id": "preparation_date", "label": "Preparation Date", "type": "date_or_placeholder", "required": true },
63
- { "id": "approval_code", "label": "Approval Code", "type": "code", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  ]
65
  },
66
  {
67
  "template_key": "html_invitation",
68
  "friendly_name": "HTML Invitation",
69
  "elements": [
70
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true },
71
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
72
- { "id": "greeting", "label": "Greeting with First Name Merge Tag", "type": "text", "required": true, "expected_phrases": ["Dear Dr", "*|FNAME|*"] },
73
- { "id": "invite_phrase", "label": "Invitation Phrase", "type": "text", "required": true, "expected_phrases": ["You are cordially invited to attend"] },
74
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
75
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
76
- { "id": "time", "label": "Time Range", "type": "time_range", "required": true },
77
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
78
- { "id": "chairpersons_block", "label": "Chairpersons Block", "type": "section", "required": true },
79
- { "id": "speakers_block", "label": "Speakers Block", "type": "section", "required": true },
80
- { "id": "agenda_table", "label": "Agenda Table", "type": "table", "required": true, "expected_columns": ["Time", "Topic", "Faculty"] },
81
- { "id": "cta_register", "label": "CTA to Register", "type": "cta", "required": true, "expected_phrases": ["Click here to register"] },
82
- { "id": "disclaimer", "label": "Disclaimer", "type": "text", "required": true },
83
- { "id": "preparation_date", "label": "Preparation Date", "type": "date_or_placeholder", "required": true },
84
- { "id": "approval_code", "label": "Approval Code", "type": "code", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  ]
86
  },
87
  {
88
  "template_key": "pdf_invitation",
89
  "friendly_name": "PDF Invitation",
90
  "elements": [
91
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true },
92
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
93
- { "id": "greeting", "label": "Greeting (Dear Doctor)", "type": "text", "required": true, "expected_phrases": ["Dear Doctor"] },
94
- { "id": "invite_phrase", "label": "Invitation Phrase", "type": "text", "required": true, "expected_phrases": ["You are cordially invited to attend"] },
95
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
96
- { "id": "date", "label": "Date", "type": "date", "required": true },
97
- { "id": "time", "label": "Time Range", "type": "time_range", "required": true },
98
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
99
- { "id": "chairpersons_block", "label": "Chairpersons Block", "type": "section", "required": true },
100
- { "id": "speakers_block", "label": "Speakers Block", "type": "section", "required": true },
101
- { "id": "agenda_table", "label": "Agenda Table", "type": "table", "required": true },
102
- { "id": "cta_register", "label": "CTA to Register", "type": "cta", "required": true },
103
- { "id": "disclaimer", "label": "Disclaimer", "type": "text", "required": true },
104
- { "id": "preparation_date", "label": "Preparation Date", "type": "date_or_placeholder", "required": true },
105
- { "id": "approval_code", "label": "Approval Code", "type": "code", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  ]
107
  },
108
  {
109
  "template_key": "pdf_save_the_date",
110
  "friendly_name": "PDF Save the Date",
111
  "elements": [
112
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true },
113
- { "id": "header_save_the_date", "label": "Save the Date Header", "type": "text", "required": true, "expected_phrases": ["Save the Date"] },
114
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
115
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
116
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
117
- { "id": "time", "label": "Time", "type": "time", "required": true },
118
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
119
- { "id": "cta_add_to_calendar", "label": "Add to Calendar CTA", "type": "cta", "required": true, "expected_phrases": ["Click here to add to your calendar"] },
120
- { "id": "chairpersons_block", "label": "Chairpersons Block", "type": "section", "required": false },
121
- { "id": "speakers_block", "label": "Speakers Block", "type": "section", "required": false },
122
- { "id": "disclaimer", "label": "Disclaimer", "type": "text", "required": true },
123
- { "id": "preparation_date", "label": "Preparation Date", "type": "date_or_placeholder", "required": true },
124
- { "id": "approval_code", "label": "Approval Code", "type": "code", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  ]
126
  },
127
  {
128
  "template_key": "printed_invitation",
129
  "friendly_name": "Printed Invitation",
130
  "elements": [
131
- { "id": "company_logo_top", "label": "Top Company Logo", "type": "logo", "required": true },
132
- { "id": "invitation_header", "label": "Invitation Header", "type": "text", "required": true, "expected_phrases": ["Invitation"] },
133
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
134
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
135
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
136
- { "id": "time", "label": "Time Range", "type": "time_range", "required": true },
137
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
138
- { "id": "welcome_or_greeting", "label": "Welcome Note or Greeting", "type": "text", "required": true, "placeholder_examples": ["<<Welcome Message>>", "Dear Doctor"] },
139
- { "id": "chairpersons_block", "label": "Chairpersons Block", "type": "section", "required": true },
140
- { "id": "speakers_block", "label": "Speakers Block", "type": "section", "required": true },
141
- { "id": "agenda_table", "label": "Agenda Table", "type": "table", "required": true },
142
- { "id": "qr_registration", "label": "QR Code for Registration", "type": "qr_code_or_image", "required": true },
143
- { "id": "company_logo_bottom", "label": "Bottom Company Logo", "type": "logo", "required": true },
144
- { "id": "disclaimer", "label": "Disclaimer", "type": "text", "required": true },
145
- { "id": "preparation_date", "label": "Preparation Date", "type": "date_or_placeholder", "required": true },
146
- { "id": "approval_code", "label": "Approval Code", "type": "code", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  ]
148
  },
149
  {
150
  "template_key": "rcp_certificate_attendance",
151
  "friendly_name": "RCP Certificate of Attendance",
152
  "elements": [
153
- { "id": "certificate_title", "label": "Certificate Title", "type": "text", "required": true, "expected_phrases": ["Royal College of Physicians Certificate of Accreditation"] },
154
- { "id": "recipient_name", "label": "Recipient Name", "type": "person_name", "required": true, "placeholder_examples": ["Dr << Name >>"] },
155
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
156
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
157
- { "id": "rcp_activity_code", "label": "RCP Activity Code Number", "type": "code", "required": true },
158
- { "id": "cpd_statement", "label": "CPD Credits Statement", "type": "text", "required": true, "expected_phrases": ["approved by the Federation of the Royal Colleges of Physicians of the United Kingdom", "category 1 (external) CPD credit"] },
159
- { "id": "event_chairperson_signature", "label": "Event Chairperson Signature", "type": "signature_block", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  ]
161
  },
162
  {
163
  "template_key": "agenda",
164
  "friendly_name": "Agenda Page",
165
  "elements": [
166
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true },
167
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
168
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
169
- { "id": "date", "label": "Date", "type": "date", "required": true },
170
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
171
- { "id": "agenda_header", "label": "Agenda Header", "type": "text", "required": true, "expected_phrases": ["Agenda"] },
172
- { "id": "agenda_table", "label": "Agenda Table", "type": "table", "required": true, "expected_columns": ["Time", "Topic", "Faculty"] },
173
- { "id": "disclaimer", "label": "Disclaimer", "type": "text", "required": true },
174
- { "id": "preparation_date", "label": "Preparation Date", "type": "date_or_placeholder", "required": true },
175
- { "id": "approval_code", "label": "Approval Code", "type": "code", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  ]
177
  },
178
  {
179
  "template_key": "certificate_accreditation_dha_full",
180
  "friendly_name": "DHA Certificate of Accreditation (President + Chairs)",
181
  "elements": [
182
- { "id": "certificate_title", "label": "Certificate Title", "type": "text", "required": true, "expected_phrases": ["Certificate of Accreditation"] },
183
- { "id": "president_name", "label": "President Name", "type": "person_name", "required": true },
184
- { "id": "chair_scientific_committee", "label": "Chair of Scientific Committee", "type": "person_name", "required": true },
185
- { "id": "chair_organizing_committee", "label": "Chair of Organizing Committee", "type": "person_name", "required": true },
186
- { "id": "recipient_name", "label": "Recipient Name", "type": "person_name", "required": true },
187
- { "id": "attendance_text", "label": "Attendance Confirmation Text", "type": "text", "required": true, "expected_phrases": ["This certificate confirms that", "Has attended"] },
188
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
189
- { "id": "cpd_points", "label": "CPD Credit Points", "type": "number_or_text", "required": true, "expected_phrases": ["Awarded", "CPD Credit Point", "Dubai Health Authority"] },
190
- { "id": "accreditation_code", "label": "Accreditation Code", "type": "code", "required": true, "expected_phrases": ["Accreditation #", "CODE"] },
191
- { "id": "completion_date", "label": "Completion Date", "type": "date", "required": true },
192
- { "id": "venue", "label": "Venue & Location", "type": "venue", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  ]
194
  },
195
  {
196
  "template_key": "certificate_appreciation_sponsor",
197
  "friendly_name": "Certificate of Appreciation (Sponsor)",
198
  "elements": [
199
- { "id": "certificate_title", "label": "Certificate Title", "type": "text", "required": true, "expected_phrases": ["Certificate of Appreciation"] },
200
- { "id": "recipient_company_name", "label": "Sponsor Company Name", "type": "company_name", "required": true, "placeholder_examples": ["<< Company Name >>"] },
201
- { "id": "purpose_text", "label": "Purpose Text", "type": "text", "required": true, "expected_phrases": ["to express our gratitude and appreciation for generously supporting"] },
202
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
203
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
204
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
205
- { "id": "event_chairperson_signature", "label": "Event Chairperson Signature", "type": "signature_block", "required": true },
206
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
207
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  ]
209
  },
210
  {
211
  "template_key": "evaluation_form_post_event",
212
  "friendly_name": "Evaluation Form (Post-Event)",
213
  "elements": [
214
- { "id": "header_title", "label": "Header Title", "type": "text", "required": true, "expected_phrases": ["Evaluation and Feedback"] },
215
- { "id": "company_reference", "label": "Company Medical & Scientific Affairs Reference", "type": "text", "required": true },
216
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
217
- { "id": "likert_questions", "label": "Likert Scale Questions (1–8)", "type": "list_of_questions", "required": true },
218
- { "id": "open_question_learnings", "label": "Open Question: Key Learnings", "type": "open_question", "required": true },
219
- { "id": "open_question_future_topics", "label": "Open Question: Future Topics", "type": "open_question", "required": true },
220
- { "id": "optional_name", "label": "Optional Name Field", "type": "person_name_field", "required": false },
221
- { "id": "optional_title", "label": "Optional Title Field", "type": "text_field", "required": false }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  ]
223
  },
224
  {
225
  "template_key": "event_booklet",
226
  "friendly_name": "Event Booklet",
227
  "elements": [
228
- { "id": "back_cover_logos", "label": "Back Cover Logos", "type": "logo_group", "required": true },
229
- { "id": "back_cover_disclaimer", "label": "Back Cover Disclaimer", "type": "text", "required": true },
230
- { "id": "back_cover_prep_approval", "label": "Back Cover Preparation Date & Approval Code", "type": "date_and_code", "required": true },
231
- { "id": "front_cover_logos", "label": "Front Cover Logos", "type": "logo_group", "required": true },
232
- { "id": "front_cover_event_name", "label": "Front Cover Event Name", "type": "event_name", "required": true },
233
- { "id": "front_cover_company_logo", "label": "Front Cover Company Logo", "type": "logo", "required": true },
234
- { "id": "event_details_block", "label": "Event Details Block (Date/Time/Venue)", "type": "section", "required": true },
235
- { "id": "welcome_note", "label": "Welcome Note", "type": "text", "required": true },
236
- { "id": "summary_objectives", "label": "Summary & Objectives", "type": "text", "required": true },
237
- { "id": "accreditation_information", "label": "Accreditation Information", "type": "text", "required": true },
238
- { "id": "program_description", "label": "Program Description", "type": "text", "required": true },
239
- { "id": "agenda_table", "label": "Agenda Table", "type": "table", "required": true },
240
- { "id": "faculty_section", "label": "Faculty Section (Chairpersons & Speakers)", "type": "section", "required": true },
241
- { "id": "speaker_page_structure", "label": "Speaker Pages Structure", "type": "section", "required": true, "expected_subfields": ["Speaker's name", "Bio", "Topic", "Time", "Short bio", "Objective", "Notes"] },
242
- { "id": "notes_section", "label": "Notes Page", "type": "notes_area", "required": false },
243
- { "id": "sponsors_logos", "label": "Sponsors Logos Section", "type": "logo_group", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  ]
245
  },
246
  {
247
  "template_key": "landing_page_registration",
248
  "friendly_name": "Landing Page & Registration",
249
  "elements": [
250
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true },
251
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
252
- { "id": "welcome_note", "label": "Welcome Note", "type": "text", "required": true },
253
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
254
- { "id": "event_overview", "label": "Event Overview & Details", "type": "text", "required": true },
255
- { "id": "date", "label": "Event Date", "type": "date", "required": true },
256
- { "id": "time", "label": "Time Range", "type": "time_range", "required": true },
257
- { "id": "venue", "label": "Venue", "type": "venue", "required": true },
258
- { "id": "chairpersons_block", "label": "Chairpersons Block", "type": "section", "required": true },
259
- { "id": "speakers_block", "label": "Speakers Block", "type": "section", "required": true },
260
- { "id": "agenda_table", "label": "Agenda Table", "type": "table", "required": true },
261
- { "id": "cta_register_buttons", "label": "Register Buttons", "type": "cta", "required": true, "expected_phrases": ["Click here to register"] },
262
- { "id": "countdown", "label": "Time Countdown Widget/Placeholder", "type": "widget_or_placeholder", "required": false },
263
- { "id": "disclaimer", "label": "Disclaimer", "type": "text", "required": true },
264
- { "id": "preparation_date", "label": "Preparation Date", "type": "date_or_placeholder", "required": true },
265
- { "id": "approval_code", "label": "Approval Code", "type": "code", "required": true },
266
- { "id": "form_first_name", "label": "Form Field: First Name", "type": "text_field", "required": true },
267
- { "id": "form_last_name", "label": "Form Field: Last Name", "type": "text_field", "required": true },
268
- { "id": "form_phone", "label": "Form Field: Phone", "type": "phone_field", "required": true },
269
- { "id": "form_email", "label": "Form Field: Email", "type": "email_field", "required": true },
270
- { "id": "form_specialty", "label": "Form Field: Specialty", "type": "select_field", "required": true },
271
- { "id": "form_country", "label": "Form Field: Country", "type": "select_field", "required": true },
272
- { "id": "form_affiliation", "label": "Form Field: Affiliation/Hospital", "type": "text_field", "required": true },
273
- { "id": "form_scfhs", "label": "Form Field: SCFHS (For Saudi only)", "type": "text_field", "required": false },
274
- { "id": "mandatory_disclaimer_tick", "label": "Mandatory Disclaimer Tick Box", "type": "checkbox", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  ]
276
  },
277
  {
278
  "template_key": "slides_permission",
279
  "friendly_name": "Slides Permission Form",
280
  "elements": [
281
- { "id": "company_logo", "label": "Company Logo", "type": "logo", "required": true },
282
- { "id": "event_logo", "label": "Event Logo", "type": "logo", "required": true },
283
- { "id": "permission_header", "label": "Permission Header", "type": "text", "required": true, "expected_phrases": ["Permission"] },
284
- { "id": "permission_body", "label": "Permission Body Text", "type": "text", "required": true, "expected_phrases": ["give my permission", "share my slides", "PDF Via email", "Video in a webcast mode"] },
285
- { "id": "company_name_reference", "label": "Company Name Reference", "type": "company_name", "required": true },
286
- { "id": "event_name", "label": "Event Name", "type": "event_name", "required": true },
287
- { "id": "event_date", "label": "Event Date", "type": "date", "required": true },
288
- { "id": "comments_section", "label": "Comments Section", "type": "multiline_text_area", "required": false },
289
- { "id": "name_field", "label": "Name Field", "type": "person_name_field", "required": true },
290
- { "id": "signature_field", "label": "Signature Field", "type": "signature_block", "required": true }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Build spell check prompt
1633
- SPELL_CHECK_PROMPT = f"""
1634
- You are a professional spell checker for medical and professional documents.
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
- OUTPUT FORMAT: Return ONLY valid JSON with no markdown formatting:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1672
  {{
1673
- "total_errors": 0,
1674
  "errors": [
1675
  {{
1676
- "word": "incorrect_word",
1677
- "context": "...surrounding text...",
1678
- "suggestions": ["suggestion1", "suggestion2", "suggestion3"],
1679
- "error_type": "spelling|grammar|typo",
1680
- "confidence": 0.95
1681
  }}
1682
  ],
1683
- "summary": "Brief summary of findings (e.g., 'Found 3 spelling errors' or 'No spelling errors found')"
 
 
 
 
 
 
 
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": SPELL_CHECK_PROMPT}]
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