Delete vrag/mentor_api.py with huggingface_hub
Browse files- vrag/mentor_api.py +0 -371
vrag/mentor_api.py
DELETED
|
@@ -1,371 +0,0 @@
|
|
| 1 |
-
"""Versioned configuration and live contracts; raw media is processed in memory only."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import base64
|
| 4 |
-
from collections import deque
|
| 5 |
-
from datetime import datetime, timezone
|
| 6 |
-
import hashlib
|
| 7 |
-
import hmac
|
| 8 |
-
import io
|
| 9 |
-
import os
|
| 10 |
-
import secrets
|
| 11 |
-
import time
|
| 12 |
-
import uuid
|
| 13 |
-
|
| 14 |
-
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
| 15 |
-
from fastapi.responses import Response
|
| 16 |
-
from PIL import Image, UnidentifiedImageError
|
| 17 |
-
from pydantic import Field, ValidationError
|
| 18 |
-
import yaml
|
| 19 |
-
|
| 20 |
-
from config.schema import Strict, parse_yaml
|
| 21 |
-
from .providers import Decision, Detection, ProviderFailure, destination
|
| 22 |
-
from .routing import route
|
| 23 |
-
from .settings import store
|
| 24 |
-
|
| 25 |
-
router = APIRouter()
|
| 26 |
-
|
| 27 |
-
def auth(request: Request, admin=False):
|
| 28 |
-
expected = os.environ.get('VM_ADMIN_TOKEN' if admin else 'VM_USER_TOKEN', '')
|
| 29 |
-
value = request.headers.get('authorization', '')
|
| 30 |
-
if not expected or len(expected) < 24 or not hmac.compare_digest(value.encode('utf-8'), ('Bearer ' + expected).encode('utf-8')):
|
| 31 |
-
raise HTTPException(401, 'configure a server token (at least 24 characters) and authenticate')
|
| 32 |
-
# Bearer authorization avoids ambient-cookie CSRF. Also reject cross-origin browser requests.
|
| 33 |
-
origin = request.headers.get('origin')
|
| 34 |
-
if origin:
|
| 35 |
-
allowed = set(filter(None, os.environ.get('VM_ALLOWED_ORIGINS', '').split(',')))
|
| 36 |
-
forwarded = request.headers.get('x-forwarded-host') if request.client and request.client.host in ('127.0.0.1', '::1', 'testclient') else None
|
| 37 |
-
host = forwarded or request.headers.get('host', '')
|
| 38 |
-
if (allowed and origin not in allowed) or (not allowed and origin not in ('https://' + host, 'http://' + host)):
|
| 39 |
-
raise HTTPException(403, 'origin denied')
|
| 40 |
-
return hashlib.sha256(value.encode()).hexdigest()
|
| 41 |
-
|
| 42 |
-
def admin(request: Request):
|
| 43 |
-
return auth(request, True)
|
| 44 |
-
|
| 45 |
-
def user(request: Request):
|
| 46 |
-
return auth(request)
|
| 47 |
-
|
| 48 |
-
class ConfigInput(Strict):
|
| 49 |
-
yaml: str = Field(max_length=65536)
|
| 50 |
-
expected_version: int = Field(default=1, ge=1)
|
| 51 |
-
|
| 52 |
-
class Rollback(Strict):
|
| 53 |
-
version: int = Field(ge=1)
|
| 54 |
-
expected_version: int = Field(ge=1)
|
| 55 |
-
|
| 56 |
-
class DryRunInput(Strict):
|
| 57 |
-
feature: str = Field(min_length=1, max_length=100)
|
| 58 |
-
camera_id: str = Field(default="camera1", pattern="^camera[12]$")
|
| 59 |
-
|
| 60 |
-
class SessionInput(Strict):
|
| 61 |
-
consent: bool
|
| 62 |
-
|
| 63 |
-
class FrameInput(Strict):
|
| 64 |
-
camera_id: str = Field(pattern='^camera[12]$')
|
| 65 |
-
frame_id: int = Field(ge=0, le=9007199254740991)
|
| 66 |
-
captured_at: float = Field(ge=0, allow_inf_nan=False)
|
| 67 |
-
image: str = Field(max_length=1500000)
|
| 68 |
-
feature: str = Field(min_length=1, max_length=100)
|
| 69 |
-
config_version: int = Field(ge=1)
|
| 70 |
-
request_id: str = Field(max_length=100)
|
| 71 |
-
|
| 72 |
-
class ProviderStatus(Strict):
|
| 73 |
-
provider: str
|
| 74 |
-
status: str
|
| 75 |
-
latency_ms: int
|
| 76 |
-
|
| 77 |
-
class Observation(Strict):
|
| 78 |
-
session_id: str
|
| 79 |
-
camera_id: str
|
| 80 |
-
frame_id: int
|
| 81 |
-
captured_at: float
|
| 82 |
-
request_id: str
|
| 83 |
-
config_version: int
|
| 84 |
-
observed_at: datetime
|
| 85 |
-
source_width: int
|
| 86 |
-
source_height: int
|
| 87 |
-
detections: list[Detection]
|
| 88 |
-
decisions: list[Decision]
|
| 89 |
-
provider_results: list[ProviderStatus]
|
| 90 |
-
warnings: list[str]
|
| 91 |
-
|
| 92 |
-
class AudioInput(Strict):
|
| 93 |
-
audio: str = Field(max_length=2800000)
|
| 94 |
-
duration_ms: int = Field(ge=1, le=15000)
|
| 95 |
-
|
| 96 |
-
class Session:
|
| 97 |
-
def __init__(self, owner):
|
| 98 |
-
self.owner = owner
|
| 99 |
-
self.expiry = time.monotonic() + 1800
|
| 100 |
-
self.last = {}
|
| 101 |
-
self.busy = set()
|
| 102 |
-
self.rate = {}
|
| 103 |
-
self.events = deque(maxlen=20)
|
| 104 |
-
self.task = {'goal':'', 'step':'Observe', 'completed':False}
|
| 105 |
-
self.last_transcript = ''
|
| 106 |
-
|
| 107 |
-
sessions = {}
|
| 108 |
-
|
| 109 |
-
def reap_sessions():
|
| 110 |
-
now = time.monotonic()
|
| 111 |
-
for sid in list(sessions):
|
| 112 |
-
if sessions[sid].expiry <= now:
|
| 113 |
-
del sessions[sid]
|
| 114 |
-
|
| 115 |
-
async def session_cleanup():
|
| 116 |
-
while True:
|
| 117 |
-
reap_sessions()
|
| 118 |
-
await asyncio.sleep(30)
|
| 119 |
-
|
| 120 |
-
def session_for(sid, owner):
|
| 121 |
-
reap_sessions()
|
| 122 |
-
s = sessions.get(sid)
|
| 123 |
-
if not s or s.owner != owner:
|
| 124 |
-
raise HTTPException(404, 'session expired or unavailable')
|
| 125 |
-
return s
|
| 126 |
-
|
| 127 |
-
@router.get('/api/health')
|
| 128 |
-
def health():
|
| 129 |
-
return {'status': 'ok', 'api_version': 1, 'inference': 'remote-only', 'config_version': store.version,
|
| 130 |
-
'configuration': 'state-directory-configured' if store.path else 'read-only-defaults',
|
| 131 |
-
'providers_configured': len(store.history[-1].providers), 'sessions': 'ephemeral'}
|
| 132 |
-
|
| 133 |
-
def provider_host(p):
|
| 134 |
-
from urllib.parse import urlsplit
|
| 135 |
-
try:
|
| 136 |
-
return urlsplit(os.environ.get(p.base_url_env, '')).hostname
|
| 137 |
-
except ValueError:
|
| 138 |
-
return None
|
| 139 |
-
|
| 140 |
-
@router.get('/api/providers')
|
| 141 |
-
def providers(owner=Depends(user)):
|
| 142 |
-
version, cfg = store.snapshot()
|
| 143 |
-
return {'config_version': version, 'providers': {k: {'label': p.label, 'adapter': p.adapter, 'model': p.model,
|
| 144 |
-
'capabilities': p.capabilities, 'trust': p.trust, 'credential_present': bool(os.environ.get(p.api_key_secret, '')) if p.api_key_secret else True,
|
| 145 |
-
'destination': provider_host(p),
|
| 146 |
-
'protocol_verified': True, 'live_status': 'not_checked'} for k,p in cfg.providers.items()},
|
| 147 |
-
'features': {k: v.model_dump() for k,v in cfg.features.items()}}
|
| 148 |
-
|
| 149 |
-
@router.get('/api/config')
|
| 150 |
-
def get_config(owner=Depends(admin)):
|
| 151 |
-
version, cfg = store.snapshot()
|
| 152 |
-
return {'config_version': version, 'yaml': yaml.safe_dump(cfg.model_dump(), sort_keys=False),
|
| 153 |
-
'history': list(range(1, version+1)), 'writable': bool(store.path), 'config':cfg.model_dump()}
|
| 154 |
-
|
| 155 |
-
@router.get('/api/config/template')
|
| 156 |
-
def template(owner=Depends(admin)):
|
| 157 |
-
from pathlib import Path
|
| 158 |
-
return Response((Path(__file__).parent.parent/'config/examples/disabled.yaml').read_text(), media_type='application/yaml', headers={'Content-Disposition': 'attachment; filename=visual-mentor.yaml'})
|
| 159 |
-
|
| 160 |
-
async def validate(text):
|
| 161 |
-
try:
|
| 162 |
-
cfg = parse_yaml(text)
|
| 163 |
-
except ValidationError as exc:
|
| 164 |
-
known = {'version','profile','providers','features','adapter','base_url_env','api_key_secret','model','capabilities','trust','timeout_ms','concurrency','strategy','result_policy','quorum','fusion','enabled','conditions','cameras','privacy','record_video','analysis_fps','width','height'}
|
| 165 |
-
errors = ['.'.join(str(k) if k in known else '<entry>' for k in e['loc']) + ': ' + e['type'] for e in exc.errors(include_input=False, include_context=False)][:8]
|
| 166 |
-
raise HTTPException(422, 'Configuration schema errors: ' + '; '.join(errors)) from None
|
| 167 |
-
except Exception:
|
| 168 |
-
# Never echo YAML or validation input (it may contain pasted secrets).
|
| 169 |
-
raise HTTPException(422, 'Invalid YAML/schema: use unique keys, no aliases, known providers and compatible capabilities; check template') from None
|
| 170 |
-
active = {pid for rule in cfg.features.values() if rule.enabled for pid in rule.providers}
|
| 171 |
-
for pid in active:
|
| 172 |
-
try:
|
| 173 |
-
await destination(cfg.providers[pid], False)
|
| 174 |
-
except ProviderFailure:
|
| 175 |
-
raise HTTPException(422, 'Provider endpoint missing or denied: configure HTTPS public host in VM_ALLOWED_HOSTS and endpoint environment variable') from None
|
| 176 |
-
return cfg
|
| 177 |
-
|
| 178 |
-
@router.get('/api/config/profiles')
|
| 179 |
-
def profiles(owner=Depends(admin)):
|
| 180 |
-
from pathlib import Path
|
| 181 |
-
directory = Path(__file__).parent.parent/'config/examples'
|
| 182 |
-
return {p.stem:p.read_text() for p in directory.glob('*.yaml')}
|
| 183 |
-
|
| 184 |
-
@router.post('/api/config/validate')
|
| 185 |
-
async def validate_config(body: ConfigInput, owner=Depends(admin)):
|
| 186 |
-
cfg = await validate(body.yaml)
|
| 187 |
-
old = store.history[-1].model_dump()
|
| 188 |
-
new = cfg.model_dump()
|
| 189 |
-
return {'valid': True, 'config_version': store.version, 'changed_sections': [k for k in new if new[k] != old[k]], 'profile': cfg.profile}
|
| 190 |
-
|
| 191 |
-
@router.post('/api/config/apply')
|
| 192 |
-
async def apply_config(body: ConfigInput, owner=Depends(admin)):
|
| 193 |
-
cfg = await validate(body.yaml)
|
| 194 |
-
try:
|
| 195 |
-
version = await store.apply(cfg, body.expected_version)
|
| 196 |
-
except ValueError as exc:
|
| 197 |
-
raise HTTPException(409, str(exc)) from None
|
| 198 |
-
return {'config_version': version, 'profile': cfg.profile}
|
| 199 |
-
|
| 200 |
-
@router.post('/api/config/rollback')
|
| 201 |
-
async def rollback(body: Rollback, owner=Depends(admin)):
|
| 202 |
-
if body.version > len(store.history):
|
| 203 |
-
raise HTTPException(404, 'unknown version')
|
| 204 |
-
cfg = store.history[body.version-1]
|
| 205 |
-
# Endpoints may have changed since this version was saved; validate again.
|
| 206 |
-
await validate(yaml.safe_dump(cfg.model_dump()))
|
| 207 |
-
try:
|
| 208 |
-
version = await store.apply(cfg, body.expected_version)
|
| 209 |
-
except ValueError as exc:
|
| 210 |
-
raise HTTPException(409, str(exc)) from None
|
| 211 |
-
return {'config_version': version, 'restored_version': body.version}
|
| 212 |
-
|
| 213 |
-
@router.post('/api/providers/{pid}/test')
|
| 214 |
-
async def test_provider(pid: str, owner=Depends(admin)):
|
| 215 |
-
from .providers import adapter
|
| 216 |
-
_, cfg = store.snapshot()
|
| 217 |
-
if pid not in cfg.providers:
|
| 218 |
-
raise HTTPException(404, 'unknown provider')
|
| 219 |
-
p = cfg.providers[pid]
|
| 220 |
-
# Only a synthetic image/text; microphone tests require an explicit bounded clip in a live session.
|
| 221 |
-
if p.adapter == 'openai-transcription':
|
| 222 |
-
raise HTTPException(409, 'Use an explicitly enabled live audio clip to test transcription')
|
| 223 |
-
image = Image.new('RGB', (32,32), 'white')
|
| 224 |
-
out = io.BytesIO(); image.save(out, format='JPEG')
|
| 225 |
-
payload = {'text': 'Synthetic connection test. Respond with OK.', 'image': 'data:image/jpeg;base64,' + base64.b64encode(out.getvalue()).decode(), 'camera_id': 'test', 'frame_id': 0, 'width':32, 'height':32}
|
| 226 |
-
if p.adapter == 'openai-chat-vision' and 'image_understanding' not in p.capabilities:
|
| 227 |
-
payload.pop('image')
|
| 228 |
-
start = time.monotonic()
|
| 229 |
-
await adapter.call(pid, p, payload, False)
|
| 230 |
-
return {'provider': pid, 'status':'ok', 'latency_ms': round((time.monotonic()-start)*1000), 'synthetic': True}
|
| 231 |
-
|
| 232 |
-
@router.post('/api/routing/dry-run')
|
| 233 |
-
async def dry_run(body: DryRunInput, owner=Depends(admin)):
|
| 234 |
-
_, cfg = store.snapshot()
|
| 235 |
-
f = io.BytesIO(); Image.new('RGB', (32,32), 'white').save(f, format='JPEG')
|
| 236 |
-
payload = {'text':'Synthetic routing test; no real camera data.',
|
| 237 |
-
'camera_id':body.camera_id, 'frame_id':0, 'width':32, 'height':32}
|
| 238 |
-
if body.feature.startswith('live.'):
|
| 239 |
-
payload['image']='data:image/jpeg;base64,' + base64.b64encode(f.getvalue()).decode()
|
| 240 |
-
out, statuses = await route(cfg, body.feature, payload, False)
|
| 241 |
-
return {'synthetic':True, 'provider_results':statuses, 'detections':[d.model_dump() for d in out.detections], 'decisions':[d.model_dump() for d in out.decisions]}
|
| 242 |
-
|
| 243 |
-
async def disconnectable(request, operation):
|
| 244 |
-
task = asyncio.create_task(operation)
|
| 245 |
-
try:
|
| 246 |
-
while not task.done():
|
| 247 |
-
done, _ = await asyncio.wait({task}, timeout=0.25)
|
| 248 |
-
if done:
|
| 249 |
-
break
|
| 250 |
-
if await request.is_disconnected():
|
| 251 |
-
task.cancel()
|
| 252 |
-
raise HTTPException(499, 'client disconnected')
|
| 253 |
-
return await task
|
| 254 |
-
finally:
|
| 255 |
-
if not task.done():
|
| 256 |
-
task.cancel()
|
| 257 |
-
await asyncio.gather(task, return_exceptions=True)
|
| 258 |
-
|
| 259 |
-
@router.post('/api/live/sessions')
|
| 260 |
-
async def create_session(body: SessionInput, owner=Depends(user)):
|
| 261 |
-
if not body.consent:
|
| 262 |
-
raise HTTPException(400, 'explicit transfer consent required')
|
| 263 |
-
reap_sessions()
|
| 264 |
-
if len(sessions) >= 32 or sum(s.owner == owner for s in sessions.values()) >= 4:
|
| 265 |
-
raise HTTPException(429, 'session limit')
|
| 266 |
-
sid = secrets.token_urlsafe(24)
|
| 267 |
-
sessions[sid] = Session(owner)
|
| 268 |
-
return {'session_id': sid, 'config_version': store.version, 'expires_in': 1800, 'retention': 'in-memory observations only'}
|
| 269 |
-
|
| 270 |
-
@router.delete('/api/live/sessions/{sid}')
|
| 271 |
-
async def delete_session(sid: str, owner=Depends(user)):
|
| 272 |
-
session_for(sid, owner)
|
| 273 |
-
del sessions[sid]
|
| 274 |
-
return {'deleted': True}
|
| 275 |
-
|
| 276 |
-
@router.post('/api/live/sessions/{sid}/frames', response_model=Observation)
|
| 277 |
-
async def analyze_frame(sid: str, body: FrameInput, request: Request, owner=Depends(user)):
|
| 278 |
-
s = session_for(sid, owner)
|
| 279 |
-
version, cfg = store.snapshot()
|
| 280 |
-
if body.config_version != version:
|
| 281 |
-
raise HTTPException(409, 'configuration changed; refresh session routing')
|
| 282 |
-
if not body.feature.startswith(f'live.{body.camera_id}.') and body.feature != 'live.visual_decision':
|
| 283 |
-
raise HTTPException(422, 'feature must belong to this logical camera')
|
| 284 |
-
if body.camera_id in s.busy:
|
| 285 |
-
raise HTTPException(429, 'one analysis per camera is already in flight')
|
| 286 |
-
if body.frame_id <= s.last.get(body.camera_id, -1):
|
| 287 |
-
raise HTTPException(409, 'stale frame')
|
| 288 |
-
if time.monotonic() - s.rate.get(body.camera_id, 0) < 0.5:
|
| 289 |
-
raise HTTPException(429, 'maximum 2 FPS')
|
| 290 |
-
try:
|
| 291 |
-
prefix, encoded = body.image.split(',', 1)
|
| 292 |
-
if prefix not in ('data:image/jpeg;base64', 'data:image/webp;base64'):
|
| 293 |
-
raise ValueError()
|
| 294 |
-
raw = base64.b64decode(encoded, validate=True)
|
| 295 |
-
with Image.open(io.BytesIO(raw)) as image:
|
| 296 |
-
if image.format not in ('JPEG','WEBP') or not (1 <= image.width <= 1920 and 1 <= image.height <= 1080):
|
| 297 |
-
raise ValueError()
|
| 298 |
-
width, height = image.size
|
| 299 |
-
image.verify()
|
| 300 |
-
except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError):
|
| 301 |
-
raise HTTPException(422, 'invalid or oversized JPEG/WebP image') from None
|
| 302 |
-
s.busy.add(body.camera_id)
|
| 303 |
-
s.last[body.camera_id] = body.frame_id
|
| 304 |
-
s.rate[body.camera_id] = time.monotonic()
|
| 305 |
-
received = datetime.now(timezone.utc).isoformat()
|
| 306 |
-
try:
|
| 307 |
-
out, statuses = await disconnectable(request, route(cfg, body.feature, {**body.model_dump(), 'width':width, 'height':height}))
|
| 308 |
-
finally:
|
| 309 |
-
s.busy.discard(body.camera_id)
|
| 310 |
-
# Deleting a session while an inference request is in flight discards its result.
|
| 311 |
-
session_for(sid, owner)
|
| 312 |
-
result = {'session_id':sid, 'camera_id':body.camera_id, 'frame_id':body.frame_id, 'captured_at':body.captured_at,
|
| 313 |
-
'request_id':body.request_id, 'config_version':version, 'observed_at':received,
|
| 314 |
-
'source_width':width, 'source_height':height, 'detections':[d.model_dump() for d in out.detections],
|
| 315 |
-
'decisions':[d.model_dump() for d in out.decisions], 'provider_results':statuses,
|
| 316 |
-
'warnings':out.warnings + (['No successful provider result'] if not any(r['status']=='ok' for r in statuses) else [])}
|
| 317 |
-
s.events.append(result)
|
| 318 |
-
return result
|
| 319 |
-
|
| 320 |
-
@router.get('/api/live/sessions/{sid}/events')
|
| 321 |
-
async def events(sid: str, owner=Depends(user)):
|
| 322 |
-
return list(session_for(sid, owner).events)
|
| 323 |
-
|
| 324 |
-
@router.post('/api/live/sessions/{sid}/transcribe')
|
| 325 |
-
async def transcribe(sid: str, body: AudioInput, request: Request, owner=Depends(user)):
|
| 326 |
-
s = session_for(sid, owner)
|
| 327 |
-
if 'audio' in s.busy:
|
| 328 |
-
raise HTTPException(429, 'audio request in flight')
|
| 329 |
-
if time.monotonic() - s.rate.get('audio', 0) < 2:
|
| 330 |
-
raise HTTPException(429, 'audio rate limit')
|
| 331 |
-
try:
|
| 332 |
-
audio = base64.b64decode(body.audio, validate=True)
|
| 333 |
-
if not audio or len(audio) > 2000000 or audio[:4] != b'\x1aE\xdf\xa3':
|
| 334 |
-
raise ValueError()
|
| 335 |
-
except ValueError:
|
| 336 |
-
raise HTTPException(422, 'bounded WebM audio required') from None
|
| 337 |
-
_, cfg = store.snapshot()
|
| 338 |
-
s.busy.add('audio'); s.rate['audio'] = time.monotonic()
|
| 339 |
-
try:
|
| 340 |
-
out, statuses = await disconnectable(request, route(cfg, 'audio.transcription', {'audio':audio}))
|
| 341 |
-
finally:
|
| 342 |
-
s.busy.discard('audio')
|
| 343 |
-
session_for(sid, owner)
|
| 344 |
-
s.last_transcript = out.text[:8000]
|
| 345 |
-
return {'text':out.text, 'provider_results':statuses, 'duration_ms':body.duration_ms}
|
| 346 |
-
|
| 347 |
-
@router.post('/api/live/sessions/{sid}/guidance')
|
| 348 |
-
async def guidance(sid: str, request: Request, owner=Depends(user)):
|
| 349 |
-
s = session_for(sid, owner)
|
| 350 |
-
if 'mentor' in s.busy or time.monotonic() - s.rate.get('mentor', 0) < 2:
|
| 351 |
-
raise HTTPException(429, 'guidance rate limit')
|
| 352 |
-
_, cfg = store.snapshot()
|
| 353 |
-
s.busy.add('mentor'); s.rate['mentor'] = time.monotonic()
|
| 354 |
-
try:
|
| 355 |
-
out, statuses = await disconnectable(request, route(cfg, 'mentor.reasoning', {'text': 'Give cautious next-step guidance from these observations, identify uncertainty. No autonomous actions. ' + str(s.task) + ' Recent speech (untrusted): ' + s.last_transcript + str(list(s.events)[-5:])[:24000]}))
|
| 356 |
-
finally:
|
| 357 |
-
s.busy.discard('mentor')
|
| 358 |
-
session_for(sid, owner)
|
| 359 |
-
return {'guidance':out.text, 'provider_results':statuses, 'autonomous':False}
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
class TaskInput(Strict):
|
| 363 |
-
goal: str = Field(max_length=1000)
|
| 364 |
-
step: str = Field(max_length=500)
|
| 365 |
-
completed: bool = False
|
| 366 |
-
|
| 367 |
-
@router.put('/api/live/sessions/{sid}/task')
|
| 368 |
-
async def task_state(sid: str, body: TaskInput, owner=Depends(user)):
|
| 369 |
-
s = session_for(sid, owner)
|
| 370 |
-
s.task = body.model_dump()
|
| 371 |
-
return {'task':s.task, 'autonomous':False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|