File size: 13,947 Bytes
d78adb0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
from fastapi import APIRouter, UploadFile, File, HTTPException, Query, Header, Depends
from typing import Any, List
from .ingest import ingest_csv_file
from .fhir import build_codesystem, build_conceptmap
from .storage import terminology_store
from .who_api import who_client
from .snomed_loinc import snomed_service, loinc_service, get_semantic_coding
from .fhir_resources import (
    create_problem_list_entry, create_encounter, create_patient, 
    create_practitioner, create_consent, create_audit_event, create_provenance
)
from .iso_22600 import check_resource_access, load_consent_from_fhir, access_control
from datetime import datetime
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pathlib import Path
import json

router = APIRouter()


def _ensure_loaded() -> None:
    # Auto-load default dataset if store is empty
    if len(terminology_store.namaste) == 0:
        data_path = Path(__file__).resolve().parents[1] / 'data' / 'namaste_200.csv'
        if data_path.exists():
            try:
                ingest_csv_file(data_path.read_bytes())
            except Exception:
                pass


@router.post('/ingest-csv')
async def ingest_csv(file: UploadFile = File(...)) -> Any:
    if not file.filename.lower().endswith('.csv'):
        raise HTTPException(status_code=400, detail='Only CSV files are supported')
    content = await file.read()
    try:
        count = ingest_csv_file(content)
    except Exception as exc:  # keep broad for hackathon demo
        raise HTTPException(status_code=400, detail=str(exc))
    return {'ingested': count}


@router.get('/codesystem')
def get_codesystem() -> Any:
    _ensure_loaded()
    return build_codesystem()


@router.get('/conceptmap')
def get_conceptmap() -> Any:
    _ensure_loaded()
    return build_conceptmap()


@router.get('/search')
def search_terms(q: str = Query(..., min_length=1)) -> Any:
    _ensure_loaded()
    return terminology_store.search(q)


@router.get('/translate')
async def translate(code: str = Query(...), system: str = Query(..., pattern='^(namaste|icd11)$')) -> Any:
    _ensure_loaded()
    raw = terminology_store.translate(code, system)
    # Normalize, de-duplicate and enrich with titles
    def norm_icd(c: str) -> str:
        return c.replace('ICD-11:', '').strip()

    # Basic demo title map (would come from WHO API/cache in production)
    title_map = {
        'TM2-AY134': 'Acid dyspepsia (TM2)',
        'K29.7': 'Gastritis, unspecified',
        '5A11': 'Type 2 diabetes mellitus',
        'TM2-AY999': 'Vata imbalance (TM2)'
    }

    matches = raw.get('matches', [])
    if system == 'namaste':
        # Return ICD targets with titles
        seen: set[str] = set()
        targets: list[dict[str, Any]] = []
        for m in matches:
            code_only = norm_icd(m.get('code', ''))
            if not code_only or code_only in seen:
                continue
            seen.add(code_only)
            targets.append({
                'code': code_only,
                'title': title_map.get(code_only, code_only),
                'system': 'ICD-11'
            })
        # Try to enrich using WHO mock data if still missing titles
        if any(t['title'] == t['code'] for t in targets):
            tm2 = await who_client.get_tm2_entities()
            bio = await who_client.get_biomedicine_entities()
            full = {e['id']: e.get('title') for e in tm2}
            full.update({e['id']: e.get('title') for e in bio})
            for t in targets:
                if t['title'] == t['code'] and t['code'] in full:
                    t['title'] = full[t['code']]
        return { 'targets': targets }
    else:
        # system == icd11 → return NAMASTE codes with labels
        seen: set[str] = set()
        targets: list[dict[str, Any]] = []
        for m in matches:
            c = m.get('code')
            if not c or c in seen:
                continue
            seen.add(c)
            term = terminology_store.namaste.get(c)
            targets.append({
                'code': c,
                'title': term.label if term else c,
                'system': 'NAMASTE'
            })
        return { 'targets': targets }


@router.get('/suggest')
def suggest(q: str = Query(..., min_length=1)) -> Any:
    _ensure_loaded()
    return terminology_store.suggest_with_confidence(q)


# --- Mock ABHA OAuth ---
@router.post('/auth')
def auth(abha_id: str = Query(...)) -> Any:
    if not abha_id or len(abha_id) < 6:
        raise HTTPException(status_code=400, detail='Invalid ABHA ID')
    # simple mock token
    token = f"mock.{abha_id}.token"
    exp = int(datetime.utcnow().timestamp()) + 3600
    return {'access_token': token, 'token_type': 'bearer', 'expires_at': exp}


def _require_auth(authorization: str | None) -> None:
    if not authorization or not authorization.startswith('Bearer mock.'):
        raise HTTPException(status_code=401, detail='Unauthorized')


# --- FHIR Bundle ingest with Audit/Provenance ---
AUDIT_LOG: list[dict[str, Any]] = []
PROV_LOG: list[dict[str, Any]] = []


@router.post('/ingest-bundle')
def ingest_bundle(bundle: dict[str, Any], authorization: str | None = Header(default=None)) -> Any:
    _require_auth(authorization)
    if bundle.get('resourceType') != 'Bundle':
        raise HTTPException(status_code=400, detail='Invalid Bundle')
    entries = bundle.get('entry') or []
    has_condition = any((e.get('resource') or {}).get('resourceType') == 'Condition' for e in entries)
    if not has_condition:
        raise HTTPException(status_code=400, detail='Bundle missing Condition resource')

    now = datetime.utcnow().isoformat() + 'Z'
    AUDIT_LOG.append({
        'resourceType': 'AuditEvent',
        'type': {'code': 'rest'},
        'action': 'C',
        'recorded': now,
        'outcome': '0',
        'agent': [{'requestor': True}],
        'source': {'site': 'ayush-fhir'},
    })
    PROV_LOG.append({
        'resourceType': 'Provenance',
        'recorded': now,
        'agent': [{'type': {'text': 'system'}, 'who': {'display': 'ayush-fhir'}}],
        'target': [{'reference': 'Bundle/new'}],
    })
    return {'status': 'accepted'}


@router.get('/audit')
def get_audit() -> Any:
    return {'entries': AUDIT_LOG}


@router.get('/provenance')
def get_provenance() -> Any:
    return {'entries': PROV_LOG}


# --- Simple stats for dashboard ---
@router.get('/stats/top-terms')
def top_terms() -> Any:
    # frequency based on presence; in real apps, count occurrences. Here, return top 5 by code order
    items = [
        {'code': t.code, 'label': t.label, 'count': 1}
        for t in list(terminology_store.namaste.values())[:5]
    ]
    return {'items': items}


@router.get('/stats/dual-coding-rate')
def dual_coding_rate() -> Any:
    total = len(terminology_store.namaste)
    dual = sum(1 for t in terminology_store.namaste.values() if t.icd11_tm2_codes)
    rate = (dual / total * 100.0) if total else 0.0
    return {'total_terms': total, 'dual_coded_terms': dual, 'rate_percent': round(rate, 2)}


@router.post('/ingest-default')
def ingest_default() -> Any:
    # Load the default 200-record CSV from data/namaste_200.csv
    data_path = Path(__file__).resolve().parents[1] / 'data' / 'namaste_200.csv'
    if not data_path.exists():
        raise HTTPException(status_code=404, detail='Default dataset not found')
    content = data_path.read_bytes()
    count = ingest_csv_file(content)
    return {'ingested': count, 'source': str(data_path.name)}


# --- WHO ICD-11 API Integration ---
@router.get('/who/tm2/search')
async def search_tm2(
    q: str | None = Query(default=None),
    query: str | None = Query(default=None)
) -> Any:
    """Search WHO ICD-11 TM2 entities"""
    _ensure_loaded()
    term = q or query
    if not term:
        raise HTTPException(status_code=422, detail="Missing ?q= or ?query=")
    
    try:
        entities = await who_client.get_tm2_entities()
        # Filter by query
        filtered = [
            e for e in entities 
            if term.lower() in e.get('title', '').lower() or 
               term.lower() in ' '.join(e.get('synonyms', [])).lower()
        ]
        return {'entities': filtered, 'count': len(filtered)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"WHO API error: {str(e)}")


@router.get('/who/biomedicine/search')
async def search_biomedicine(query: str = Query(..., min_length=1)) -> Any:
    """Search WHO ICD-11 Biomedicine entities"""
    _ensure_loaded()
    try:
        entities = await who_client.get_biomedicine_entities(query)
        return {'entities': entities, 'count': len(entities)}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"WHO API error: {str(e)}")


# --- SNOMED CT / LOINC Integration ---
@router.get('/snomed/search')
def search_snomed(
    q: str | None = Query(default=None),
    query: str | None = Query(default=None),
) -> Any:
    """Search SNOMED CT concepts"""
    _ensure_loaded()
    term = q or query
    if not term:
        raise HTTPException(status_code=422, detail="Missing ?q= or ?query=")
    concepts = snomed_service.search_concepts(term)
    return {
        'concepts': [
            {'code': c.code, 'display': c.display, 'system': c.system, 'category': c.category}
            for c in concepts
        ],
        'count': len(concepts)
    }

@router.get('/loinc/search')
def search_loinc(
    q: str | None = Query(default=None),
    query: str | None = Query(default=None),
) -> Any:
    """Search LOINC codes"""
    _ensure_loaded()
    term = q or query
    if not term:
        raise HTTPException(status_code=422, detail="Missing ?q= or ?query=")
    codes = loinc_service.search_codes(term)
    return {
        'codes': [
            {'code': c.code, 'display': c.display, 'system': c.system, 'category': c.category}
            for c in codes
        ],
        'count': len(codes)
    }
# --- FHIR Problem List with Dual Coding ---
@router.post('/fhir/problem-list')
def create_problem_list(
    namaste_code: str = Query(...),
    patient_id: str = Query(default="patient-001"),
    practitioner_id: str = Query(default="practitioner-001"),
    encounter_id: str = Query(default="encounter-001"),
    authorization: str | None = Header(default=None)
) -> Any:
    """Create FHIR Problem List entry with dual coding"""
    _ensure_loaded()
    _require_auth(authorization)
    
    # Get ICD-11 codes for the NAMASTE term
    term = terminology_store.namaste.get(namaste_code)
    if not term:
        raise HTTPException(status_code=404, detail=f"NAMASTE code {namaste_code} not found")
    
    icd11_codes = term.icd11_tm2_codes
    
    try:
        # Create Problem List entry
        condition = create_problem_list_entry(
            namaste_code=namaste_code,
            icd11_codes=icd11_codes,
            patient_id=patient_id,
            practitioner_id=practitioner_id,
            encounter_id=encounter_id
        )
        
        # Create audit event
        audit_event = create_audit_event(
            action="C",
            outcome="0",
            agent_name="Ayush FHIR Service",
            resource_type="Condition"
        )
        
        # Create provenance
        provenance = create_provenance(
            target_reference=f"Condition/{condition['id']}",
            agent_name="Ayush FHIR Service"
        )
        
        return {
            'condition': condition,
            'audit_event': audit_event,
            'provenance': provenance,
            'dual_coding': {
                'namaste': {
                    'code': namaste_code,
                    'display': term.label
                },
                'icd11': [
                    {
                        'code': code,
                        'display': f"{code} (TM2)" if code.startswith("TM2-") else f"{code} (Biomedicine)"
                    }
                    for code in icd11_codes
                ]
            }
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error creating problem list: {str(e)}")


# --- ISO 22600 Access Control ---
@router.post('/consent')
def create_consent_resource(
    patient_id: str = Query(...),
    purpose: str = Query(default="TREATMENT"),
    authorization: str | None = Header(default=None)
) -> Any:
    """Create FHIR Consent resource"""
    _require_auth(authorization)
    
    consent = create_consent(patient_id=patient_id, purpose=purpose)
    
    # Load consent rules into access control
    load_consent_from_fhir(consent)
    
    return consent


@router.post('/access-check')
def check_access(
    subject_id: str = Query(...),
    subject_type: str = Query(...),
    subject_roles: str = Query(...),  # Comma-separated roles
    action: str = Query(...),
    resource_type: str = Query(...),
    resource_id: str = Query(...),
    purpose: str = Query(default="TREATMENT"),
    patient_id: str | None = Query(default=None),
    authorization: str | None = Header(default=None)
) -> Any:
    """Check resource access per ISO 22600"""
    _require_auth(authorization)
    
    roles = [role.strip() for role in subject_roles.split(',')]
    
    allowed, reason = check_resource_access(
        subject_id=subject_id,
        subject_type=subject_type,
        subject_roles=roles,
        action=action,
        resource_type=resource_type,
        resource_id=resource_id,
        purpose=purpose,
        patient_id=patient_id
    )
    
    return {
        'allowed': allowed,
        'reason': reason,
        'request': {
            'subject_id': subject_id,
            'subject_type': subject_type,
            'subject_roles': roles,
            'action': action,
            'resource_type': resource_type,
            'resource_id': resource_id,
            'purpose': purpose,
            'patient_id': patient_id
        }
    }