Spaces:
Running
Running
Update src/aibom_generator/api.py
Browse files- src/aibom_generator/api.py +3 -327
src/aibom_generator/api.py
CHANGED
@@ -1,9 +1,6 @@
|
|
1 |
import os
|
2 |
import json
|
3 |
import logging
|
4 |
-
import uuid
|
5 |
-
import shutil
|
6 |
-
import tempfile
|
7 |
from typing import Dict, List, Optional, Any, Union
|
8 |
from datetime import datetime
|
9 |
from pathlib import Path
|
@@ -73,77 +70,6 @@ async def startup_event():
|
|
73 |
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
74 |
logger.info(f"Created output directory at {OUTPUT_DIR}")
|
75 |
|
76 |
-
# Helper function to ensure enhancement report has the expected structure
|
77 |
-
def ensure_enhancement_report_structure(enhancement_report):
|
78 |
-
"""Ensure the enhancement report has all the expected fields for templates."""
|
79 |
-
if enhancement_report is None:
|
80 |
-
enhancement_report = {}
|
81 |
-
|
82 |
-
# Ensure ai_enhanced exists
|
83 |
-
if "ai_enhanced" not in enhancement_report:
|
84 |
-
enhancement_report["ai_enhanced"] = False
|
85 |
-
|
86 |
-
# Ensure ai_model exists
|
87 |
-
if "ai_model" not in enhancement_report:
|
88 |
-
enhancement_report["ai_model"] = "Default AI Model"
|
89 |
-
|
90 |
-
# Ensure original_score exists and has completeness_score
|
91 |
-
if "original_score" not in enhancement_report:
|
92 |
-
enhancement_report["original_score"] = {"total_score": 0, "completeness_score": 0}
|
93 |
-
elif "completeness_score" not in enhancement_report["original_score"]:
|
94 |
-
enhancement_report["original_score"]["completeness_score"] = enhancement_report["original_score"].get("total_score", 0)
|
95 |
-
|
96 |
-
# Ensure final_score exists and has completeness_score
|
97 |
-
if "final_score" not in enhancement_report:
|
98 |
-
enhancement_report["final_score"] = {"total_score": 0, "completeness_score": 0}
|
99 |
-
elif "completeness_score" not in enhancement_report["final_score"]:
|
100 |
-
enhancement_report["final_score"]["completeness_score"] = enhancement_report["final_score"].get("total_score", 0)
|
101 |
-
|
102 |
-
# Ensure improvement exists
|
103 |
-
if "improvement" not in enhancement_report:
|
104 |
-
enhancement_report["improvement"] = 0
|
105 |
-
|
106 |
-
return enhancement_report
|
107 |
-
|
108 |
-
# Helper function to ensure completeness_score has the expected structure
|
109 |
-
def ensure_completeness_score_structure(completeness_score):
|
110 |
-
"""Ensure the completeness_score has all the expected fields for templates."""
|
111 |
-
if completeness_score is None:
|
112 |
-
completeness_score = {}
|
113 |
-
|
114 |
-
# Ensure total_score exists
|
115 |
-
if "total_score" not in completeness_score:
|
116 |
-
completeness_score["total_score"] = 0
|
117 |
-
|
118 |
-
# Ensure completeness_score exists
|
119 |
-
if "completeness_score" not in completeness_score:
|
120 |
-
completeness_score["completeness_score"] = completeness_score.get("total_score", 0)
|
121 |
-
|
122 |
-
# Ensure section_scores exists
|
123 |
-
if "section_scores" not in completeness_score:
|
124 |
-
completeness_score["section_scores"] = {}
|
125 |
-
|
126 |
-
# Ensure max_scores exists
|
127 |
-
if "max_scores" not in completeness_score:
|
128 |
-
completeness_score["max_scores"] = {}
|
129 |
-
|
130 |
-
# Ensure field_checklist exists
|
131 |
-
if "field_checklist" not in completeness_score:
|
132 |
-
completeness_score["field_checklist"] = {}
|
133 |
-
|
134 |
-
# Ensure field_tiers exists
|
135 |
-
if "field_tiers" not in completeness_score:
|
136 |
-
completeness_score["field_tiers"] = {}
|
137 |
-
|
138 |
-
# Ensure completeness_profile exists
|
139 |
-
if "completeness_profile" not in completeness_score:
|
140 |
-
completeness_score["completeness_profile"] = {
|
141 |
-
"name": "Incomplete",
|
142 |
-
"description": "This AI SBOM needs improvement to meet transparency requirements."
|
143 |
-
}
|
144 |
-
|
145 |
-
return completeness_score
|
146 |
-
|
147 |
# Root route serves the UI
|
148 |
@app.get("/", response_class=HTMLResponse)
|
149 |
async def root(request: Request):
|
@@ -188,15 +114,14 @@ async def ui(request: Request):
|
|
188 |
"""Serve the web UI interface (kept for backward compatibility)."""
|
189 |
return await root(request)
|
190 |
|
191 |
-
# Status endpoint
|
192 |
@app.get("/status", response_model=StatusResponse)
|
193 |
-
@app.get("/api/status", response_model=StatusResponse)
|
194 |
async def get_status():
|
195 |
"""Get the API status and version information."""
|
196 |
return {
|
197 |
"status": "operational",
|
198 |
"version": "1.0.0",
|
199 |
-
"generator_version": "1.0
|
200 |
}
|
201 |
|
202 |
# Form-based generate endpoint for web UI
|
@@ -254,23 +179,13 @@ async def generate_form(
|
|
254 |
</script>
|
255 |
""" % (download_url, filename)
|
256 |
|
257 |
-
# Process enhancement report for template
|
258 |
-
enhancement_report = ensure_enhancement_report_structure(enhancement_report)
|
259 |
-
logger.info(f"Enhancement report structure: {enhancement_report}")
|
260 |
-
|
261 |
# Get completeness score
|
262 |
completeness_score = None
|
263 |
if hasattr(generator, 'get_completeness_score'):
|
264 |
try:
|
265 |
completeness_score = generator.get_completeness_score(model_id)
|
266 |
-
completeness_score = ensure_completeness_score_structure(completeness_score)
|
267 |
-
logger.info(f"Completeness score structure: {completeness_score}")
|
268 |
except Exception as e:
|
269 |
logger.error(f"Error getting completeness score: {str(e)}")
|
270 |
-
completeness_score = ensure_completeness_score_structure(None)
|
271 |
-
else:
|
272 |
-
# Create a default completeness score if the method doesn't exist
|
273 |
-
completeness_score = ensure_completeness_score_structure(None)
|
274 |
|
275 |
# Render result template
|
276 |
return templates.TemplateResponse(
|
@@ -352,9 +267,6 @@ async def generate_with_report_api(request: GenerateWithReportRequest):
|
|
352 |
use_best_practices=request.use_best_practices
|
353 |
)
|
354 |
|
355 |
-
# Process enhancement report for API response
|
356 |
-
enhancement_report = ensure_enhancement_report_structure(enhancement_report)
|
357 |
-
|
358 |
return {
|
359 |
"aibom": aibom,
|
360 |
"enhancement_report": enhancement_report
|
@@ -362,100 +274,6 @@ async def generate_with_report_api(request: GenerateWithReportRequest):
|
|
362 |
except Exception as e:
|
363 |
raise HTTPException(status_code=500, detail=f"Error generating AI SBOM: {str(e)}")
|
364 |
|
365 |
-
@app.post("/api/batch-generate")
|
366 |
-
async def batch_generate_api(
|
367 |
-
request: BatchGenerateRequest,
|
368 |
-
background_tasks: BackgroundTasks
|
369 |
-
):
|
370 |
-
"""Start a batch job to generate multiple AI SBOMs."""
|
371 |
-
try:
|
372 |
-
# Generate job ID
|
373 |
-
job_id = str(uuid.uuid4())
|
374 |
-
|
375 |
-
# Create job directory
|
376 |
-
job_dir = os.path.join(OUTPUT_DIR, job_id)
|
377 |
-
os.makedirs(job_dir, exist_ok=True)
|
378 |
-
|
379 |
-
# Create job status file
|
380 |
-
status_file = os.path.join(job_dir, "status.json")
|
381 |
-
with open(status_file, "w") as f:
|
382 |
-
json.dump({
|
383 |
-
"job_id": job_id,
|
384 |
-
"status": "pending",
|
385 |
-
"model_ids": request.model_ids,
|
386 |
-
"completed": 0,
|
387 |
-
"total": len(request.model_ids),
|
388 |
-
"results": {}
|
389 |
-
}, f, indent=2)
|
390 |
-
|
391 |
-
# Start background task
|
392 |
-
background_tasks.add_task(
|
393 |
-
process_batch_job,
|
394 |
-
job_id=job_id,
|
395 |
-
model_ids=request.model_ids,
|
396 |
-
include_inference=request.include_inference,
|
397 |
-
use_best_practices=request.use_best_practices
|
398 |
-
)
|
399 |
-
|
400 |
-
return {
|
401 |
-
"job_id": job_id,
|
402 |
-
"status": "pending",
|
403 |
-
"model_ids": request.model_ids,
|
404 |
-
"message": f"Batch job started. {len(request.model_ids)} models will be processed."
|
405 |
-
}
|
406 |
-
except Exception as e:
|
407 |
-
raise HTTPException(status_code=500, detail=f"Error starting batch job: {str(e)}")
|
408 |
-
|
409 |
-
@app.get("/api/batch-status/{job_id}")
|
410 |
-
async def batch_status_api(job_id: str):
|
411 |
-
"""Get the status of a batch job."""
|
412 |
-
try:
|
413 |
-
# Check if job exists
|
414 |
-
job_dir = os.path.join(OUTPUT_DIR, job_id)
|
415 |
-
if not os.path.exists(job_dir):
|
416 |
-
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
417 |
-
|
418 |
-
# Read job status
|
419 |
-
status_file = os.path.join(job_dir, "status.json")
|
420 |
-
with open(status_file, "r") as f:
|
421 |
-
status = json.load(f)
|
422 |
-
|
423 |
-
return status
|
424 |
-
except HTTPException:
|
425 |
-
raise
|
426 |
-
except Exception as e:
|
427 |
-
raise HTTPException(status_code=500, detail=f"Error getting job status: {str(e)}")
|
428 |
-
|
429 |
-
@app.get("/api/batch-results/{job_id}")
|
430 |
-
async def batch_results_api(job_id: str):
|
431 |
-
"""Get the results of a completed batch job."""
|
432 |
-
try:
|
433 |
-
# Check if job exists
|
434 |
-
job_dir = os.path.join(OUTPUT_DIR, job_id)
|
435 |
-
if not os.path.exists(job_dir):
|
436 |
-
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
437 |
-
|
438 |
-
# Read job status
|
439 |
-
status_file = os.path.join(job_dir, "status.json")
|
440 |
-
with open(status_file, "r") as f:
|
441 |
-
status = json.load(f)
|
442 |
-
|
443 |
-
# Check if job is completed
|
444 |
-
if status["status"] != "completed":
|
445 |
-
raise HTTPException(status_code=400, detail=f"Job {job_id} is not completed yet")
|
446 |
-
|
447 |
-
# Read results
|
448 |
-
results = {}
|
449 |
-
for model_id, result_file in status["results"].items():
|
450 |
-
with open(os.path.join(job_dir, result_file), "r") as f:
|
451 |
-
results[model_id] = json.load(f)
|
452 |
-
|
453 |
-
return results
|
454 |
-
except HTTPException:
|
455 |
-
raise
|
456 |
-
except Exception as e:
|
457 |
-
raise HTTPException(status_code=500, detail=f"Error getting job results: {str(e)}")
|
458 |
-
|
459 |
@app.get("/api/model-score/{model_id}")
|
460 |
async def model_score_api(model_id: str):
|
461 |
"""Get the completeness score for a model."""
|
@@ -478,58 +296,12 @@ async def model_score_api(model_id: str):
|
|
478 |
# Get completeness score
|
479 |
if hasattr(generator, 'get_completeness_score'):
|
480 |
completeness_score = generator.get_completeness_score(model_id)
|
481 |
-
completeness_score = ensure_completeness_score_structure(completeness_score)
|
482 |
return completeness_score
|
483 |
else:
|
484 |
-
|
485 |
-
completeness_score = ensure_completeness_score_structure(None)
|
486 |
-
return completeness_score
|
487 |
except Exception as e:
|
488 |
raise HTTPException(status_code=500, detail=f"Error getting model score: {str(e)}")
|
489 |
|
490 |
-
@app.post("/api/upload-model-card")
|
491 |
-
async def upload_model_card_api(
|
492 |
-
model_id: str = Form(...),
|
493 |
-
model_card: UploadFile = File(...)
|
494 |
-
):
|
495 |
-
"""Upload a model card file for a model."""
|
496 |
-
try:
|
497 |
-
# Create temporary file
|
498 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=".md") as temp_file:
|
499 |
-
# Write uploaded file to temporary file
|
500 |
-
shutil.copyfileobj(model_card.file, temp_file)
|
501 |
-
|
502 |
-
# Import the generator here to avoid circular imports
|
503 |
-
try:
|
504 |
-
from src.aibom_generator.generator import AIBOMGenerator
|
505 |
-
except ImportError:
|
506 |
-
try:
|
507 |
-
from aibom_generator.generator import AIBOMGenerator
|
508 |
-
except ImportError:
|
509 |
-
try:
|
510 |
-
from generator import AIBOMGenerator
|
511 |
-
except ImportError:
|
512 |
-
raise ImportError("Could not import AIBOMGenerator. Please check your installation.")
|
513 |
-
|
514 |
-
# Create generator instance
|
515 |
-
generator = AIBOMGenerator()
|
516 |
-
|
517 |
-
# Process model card
|
518 |
-
if hasattr(generator, 'process_model_card'):
|
519 |
-
result = generator.process_model_card(model_id, temp_file.name)
|
520 |
-
|
521 |
-
# Clean up temporary file
|
522 |
-
os.unlink(temp_file.name)
|
523 |
-
|
524 |
-
return result
|
525 |
-
else:
|
526 |
-
# Clean up temporary file
|
527 |
-
os.unlink(temp_file.name)
|
528 |
-
|
529 |
-
raise HTTPException(status_code=501, detail="Model card processing not implemented")
|
530 |
-
except Exception as e:
|
531 |
-
raise HTTPException(status_code=500, detail=f"Error processing model card: {str(e)}")
|
532 |
-
|
533 |
@app.get("/download/{filename}")
|
534 |
async def download_file(filename: str):
|
535 |
"""Download a generated AI SBOM file."""
|
@@ -547,99 +319,3 @@ async def download_file(filename: str):
|
|
547 |
raise
|
548 |
except Exception as e:
|
549 |
raise HTTPException(status_code=500, detail=f"Error downloading file: {str(e)}")
|
550 |
-
|
551 |
-
# Background task for batch processing
|
552 |
-
async def process_batch_job(
|
553 |
-
job_id: str,
|
554 |
-
model_ids: List[str],
|
555 |
-
include_inference: bool,
|
556 |
-
use_best_practices: bool
|
557 |
-
):
|
558 |
-
"""Process a batch job in the background."""
|
559 |
-
try:
|
560 |
-
# Import the generator here to avoid circular imports
|
561 |
-
try:
|
562 |
-
from src.aibom_generator.generator import AIBOMGenerator
|
563 |
-
except ImportError:
|
564 |
-
try:
|
565 |
-
from aibom_generator.generator import AIBOMGenerator
|
566 |
-
except ImportError:
|
567 |
-
try:
|
568 |
-
from generator import AIBOMGenerator
|
569 |
-
except ImportError:
|
570 |
-
raise ImportError("Could not import AIBOMGenerator. Please check your installation.")
|
571 |
-
|
572 |
-
# Create generator instance
|
573 |
-
generator = AIBOMGenerator()
|
574 |
-
|
575 |
-
# Create job directory
|
576 |
-
job_dir = os.path.join(OUTPUT_DIR, job_id)
|
577 |
-
|
578 |
-
# Read job status
|
579 |
-
status_file = os.path.join(job_dir, "status.json")
|
580 |
-
with open(status_file, "r") as f:
|
581 |
-
status = json.load(f)
|
582 |
-
|
583 |
-
# Update status to running
|
584 |
-
status["status"] = "running"
|
585 |
-
with open(status_file, "w") as f:
|
586 |
-
json.dump(status, f, indent=2)
|
587 |
-
|
588 |
-
# Process each model
|
589 |
-
for i, model_id in enumerate(model_ids):
|
590 |
-
try:
|
591 |
-
# Generate AIBOM
|
592 |
-
aibom, enhancement_report = generator.generate(
|
593 |
-
model_id=model_id,
|
594 |
-
include_inference=include_inference,
|
595 |
-
use_best_practices=use_best_practices
|
596 |
-
)
|
597 |
-
|
598 |
-
# Process enhancement report for API response
|
599 |
-
enhancement_report = ensure_enhancement_report_structure(enhancement_report)
|
600 |
-
|
601 |
-
# Save result to file
|
602 |
-
result_file = f"{model_id.replace('/', '_')}_aibom.json"
|
603 |
-
result_path = os.path.join(job_dir, result_file)
|
604 |
-
with open(result_path, "w") as f:
|
605 |
-
json.dump({
|
606 |
-
"aibom": aibom,
|
607 |
-
"enhancement_report": enhancement_report
|
608 |
-
}, f, indent=2)
|
609 |
-
|
610 |
-
# Update status
|
611 |
-
status["completed"] += 1
|
612 |
-
status["results"][model_id] = result_file
|
613 |
-
with open(status_file, "w") as f:
|
614 |
-
json.dump(status, f, indent=2)
|
615 |
-
except Exception as e:
|
616 |
-
# Log error
|
617 |
-
logger.error(f"Error processing model {model_id}: {str(e)}")
|
618 |
-
|
619 |
-
# Update status
|
620 |
-
status["completed"] += 1
|
621 |
-
status["results"][model_id] = f"Error: {str(e)}"
|
622 |
-
with open(status_file, "w") as f:
|
623 |
-
json.dump(status, f, indent=2)
|
624 |
-
|
625 |
-
# Update status to completed
|
626 |
-
status["status"] = "completed"
|
627 |
-
with open(status_file, "w") as f:
|
628 |
-
json.dump(status, f, indent=2)
|
629 |
-
except Exception as e:
|
630 |
-
# Log error
|
631 |
-
logger.error(f"Error processing batch job {job_id}: {str(e)}")
|
632 |
-
|
633 |
-
try:
|
634 |
-
# Update status to failed
|
635 |
-
status_file = os.path.join(OUTPUT_DIR, job_id, "status.json")
|
636 |
-
with open(status_file, "r") as f:
|
637 |
-
status = json.load(f)
|
638 |
-
|
639 |
-
status["status"] = "failed"
|
640 |
-
status["error"] = str(e)
|
641 |
-
|
642 |
-
with open(status_file, "w") as f:
|
643 |
-
json.dump(status, f, indent=2)
|
644 |
-
except Exception as status_e:
|
645 |
-
logger.error(f"Error updating job status: {str(status_e)}")
|
|
|
1 |
import os
|
2 |
import json
|
3 |
import logging
|
|
|
|
|
|
|
4 |
from typing import Dict, List, Optional, Any, Union
|
5 |
from datetime import datetime
|
6 |
from pathlib import Path
|
|
|
70 |
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
71 |
logger.info(f"Created output directory at {OUTPUT_DIR}")
|
72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
73 |
# Root route serves the UI
|
74 |
@app.get("/", response_class=HTMLResponse)
|
75 |
async def root(request: Request):
|
|
|
114 |
"""Serve the web UI interface (kept for backward compatibility)."""
|
115 |
return await root(request)
|
116 |
|
117 |
+
# Status endpoint
|
118 |
@app.get("/status", response_model=StatusResponse)
|
|
|
119 |
async def get_status():
|
120 |
"""Get the API status and version information."""
|
121 |
return {
|
122 |
"status": "operational",
|
123 |
"version": "1.0.0",
|
124 |
+
"generator_version": "0.1.0",
|
125 |
}
|
126 |
|
127 |
# Form-based generate endpoint for web UI
|
|
|
179 |
</script>
|
180 |
""" % (download_url, filename)
|
181 |
|
|
|
|
|
|
|
|
|
182 |
# Get completeness score
|
183 |
completeness_score = None
|
184 |
if hasattr(generator, 'get_completeness_score'):
|
185 |
try:
|
186 |
completeness_score = generator.get_completeness_score(model_id)
|
|
|
|
|
187 |
except Exception as e:
|
188 |
logger.error(f"Error getting completeness score: {str(e)}")
|
|
|
|
|
|
|
|
|
189 |
|
190 |
# Render result template
|
191 |
return templates.TemplateResponse(
|
|
|
267 |
use_best_practices=request.use_best_practices
|
268 |
)
|
269 |
|
|
|
|
|
|
|
270 |
return {
|
271 |
"aibom": aibom,
|
272 |
"enhancement_report": enhancement_report
|
|
|
274 |
except Exception as e:
|
275 |
raise HTTPException(status_code=500, detail=f"Error generating AI SBOM: {str(e)}")
|
276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
277 |
@app.get("/api/model-score/{model_id}")
|
278 |
async def model_score_api(model_id: str):
|
279 |
"""Get the completeness score for a model."""
|
|
|
296 |
# Get completeness score
|
297 |
if hasattr(generator, 'get_completeness_score'):
|
298 |
completeness_score = generator.get_completeness_score(model_id)
|
|
|
299 |
return completeness_score
|
300 |
else:
|
301 |
+
raise HTTPException(status_code=501, detail="Completeness score calculation not implemented")
|
|
|
|
|
302 |
except Exception as e:
|
303 |
raise HTTPException(status_code=500, detail=f"Error getting model score: {str(e)}")
|
304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
305 |
@app.get("/download/{filename}")
|
306 |
async def download_file(filename: str):
|
307 |
"""Download a generated AI SBOM file."""
|
|
|
319 |
raise
|
320 |
except Exception as e:
|
321 |
raise HTTPException(status_code=500, detail=f"Error downloading file: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|