Update src/reasoning_panel.py
Browse files- src/reasoning_panel.py +46 -197
src/reasoning_panel.py
CHANGED
|
@@ -17,7 +17,7 @@ Iteration 2 note:
|
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
from typing import List, Dict, Any, Tuple
|
| 20 |
-
import json, html
|
| 21 |
|
| 22 |
import streamlit as st
|
| 23 |
import streamlit.components.v1 as components
|
|
@@ -30,6 +30,9 @@ from .explainability import (
|
|
| 30 |
from .rag_index import search_index
|
| 31 |
from .paths import faiss_index_dir
|
| 32 |
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
def _normalize_features(pairs: List[Tuple[str, float]], top_k: int = 5) -> List[Dict[str, float]]:
|
| 35 |
# keep top_k and L1-normalize
|
|
@@ -44,13 +47,16 @@ def _best_snippet_for_ref(claim: str, doc: str, page: int, top_k: int = 6) -> st
|
|
| 44 |
Heuristic snippet lookup: query FAISS by the claim text and preferentially
|
| 45 |
return a chunk from the requested doc/page. Falls back gracefully.
|
| 46 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
try:
|
| 48 |
-
# Use the same embed model that was used to build the FAISS index
|
| 49 |
results = search_index(
|
| 50 |
faiss_index_dir(),
|
| 51 |
claim,
|
| 52 |
top_k=top_k,
|
| 53 |
-
embed_model=
|
| 54 |
device="cpu",
|
| 55 |
)
|
| 56 |
except TypeError:
|
|
@@ -59,8 +65,11 @@ def _best_snippet_for_ref(claim: str, doc: str, page: int, top_k: int = 6) -> st
|
|
| 59 |
faiss_index_dir(),
|
| 60 |
claim,
|
| 61 |
top_k=top_k,
|
| 62 |
-
embed_model=
|
| 63 |
)
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# exact doc+page match
|
| 66 |
for r in results or []:
|
|
@@ -81,8 +90,6 @@ def _best_snippet_for_ref(claim: str, doc: str, page: int, top_k: int = 6) -> st
|
|
| 81 |
return ""
|
| 82 |
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
def build_panel_data(
|
| 87 |
assessment: str,
|
| 88 |
plan: str,
|
|
@@ -92,222 +99,64 @@ def build_panel_data(
|
|
| 92 |
mode: str = "sim",
|
| 93 |
) -> List[Dict[str, Any]]:
|
| 94 |
text_for_claims = (assessment or "").strip() + (" " if assessment and plan else "") + (plan or "").strip()
|
|
|
|
|
|
|
|
|
|
| 95 |
claims = segment_claims(text_for_claims)
|
|
|
|
|
|
|
|
|
|
| 96 |
citation_idx = build_citation_index(citations or [])
|
| 97 |
exp = explain_claims_sim_only(claims, pcp_summary or "", specialty or "")
|
| 98 |
-
# NOTE: attention re-rank
|
| 99 |
|
| 100 |
panel: List[Dict[str, Any]] = []
|
| 101 |
for e in exp:
|
| 102 |
-
claim = e.get("claim"
|
|
|
|
|
|
|
|
|
|
| 103 |
# Merge guideline + pcp token weights (max-pool then normalize)
|
| 104 |
-
weights = {}
|
| 105 |
for tok, w in (e.get("top_tokens_guideline") or []):
|
| 106 |
-
|
| 107 |
-
if not
|
| 108 |
continue
|
| 109 |
-
weights[
|
| 110 |
for tok, w in (e.get("top_tokens_pcp") or []):
|
| 111 |
-
|
| 112 |
-
if not
|
| 113 |
continue
|
| 114 |
-
weights[
|
|
|
|
| 115 |
features = _normalize_features(list(weights.items()), top_k=5)
|
| 116 |
|
| 117 |
# Map refs -> label + snippet
|
| 118 |
refs_out: List[Dict[str, Any]] = []
|
| 119 |
for r in (e.get("guideline_refs") or []):
|
| 120 |
doc = r.get("doc")
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
| 122 |
num = citation_idx.get((doc, page))
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
snippet = _best_snippet_for_ref(claim, doc, page)
|
| 125 |
refs_out.append({"id": f"{doc}-{page}", "label": label, "snippet": snippet})
|
|
|
|
| 126 |
panel.append({"claim": claim, "features": features, "refs": refs_out})
|
|
|
|
| 127 |
return panel
|
| 128 |
|
| 129 |
|
| 130 |
def _escape_script_json(d: Any) -> str:
|
| 131 |
s = json.dumps(d, ensure_ascii=False)
|
| 132 |
-
return s.replace("</",
|
| 133 |
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
panel_data: List[Dict[str, Any]],
|
| 137 |
-
assessment: str,
|
| 138 |
-
plan: str,
|
| 139 |
-
citations: List[Dict[str, Any]] = None,
|
| 140 |
-
height: int = 720,
|
| 141 |
-
key: str = None
|
| 142 |
-
):
|
| 143 |
-
"""Render dual-pane (SOAP viewer ↔ Reasoning Panel) as an inline component.
|
| 144 |
-
|
| 145 |
-
Left pane shows the claims extracted from Assessment+Plan (read-only preview),
|
| 146 |
-
which keeps scroll-sync deterministic and fast. The editable textareas remain
|
| 147 |
-
on the Streamlit page above this component.
|
| 148 |
-
"""
|
| 149 |
-
citations = citations or []
|
| 150 |
-
data_js = _escape_script_json(panel_data)
|
| 151 |
-
refs_js = _escape_script_json(citations)
|
| 152 |
-
|
| 153 |
-
left_claims_html = "".join(
|
| 154 |
-
f'<p class="soap-claim"><span class="marker"></span>{html.escape(d.get("claim",""))}</p>'
|
| 155 |
-
for d in panel_data
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
html_code = f"""
|
| 159 |
-
<div id="rpv4-root">
|
| 160 |
-
<style>
|
| 161 |
-
:root {{
|
| 162 |
-
--bg: #ffffff; --fg: #111; --muted: #6b7280; --surface: #f8fafc; --accent: #2563eb;
|
| 163 |
-
--accent-weak: rgba(37,99,235,.08); --border: #e5e7eb;
|
| 164 |
-
}}
|
| 165 |
-
@media (prefers-color-scheme: dark) {{
|
| 166 |
-
:root {{
|
| 167 |
-
--bg: #0b0f17; --fg: #eaeef5; --muted: #a8b3cf; --surface: #111827; --accent: #60a5fa;
|
| 168 |
-
--accent-weak: rgba(96,165,250,.12); --border: #263145;
|
| 169 |
-
}}
|
| 170 |
-
}}
|
| 171 |
-
#rpv4-root {{ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; color: var(--fg); }}
|
| 172 |
-
.grid {{ display: grid; grid-template-columns: 1fr 0.9fr; gap: 16px; align-items: start; }}
|
| 173 |
-
@media (max-width: 980px) {{ .grid {{ grid-template-columns: 1fr; }} }}
|
| 174 |
-
.panel {{ background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; overflow: auto; max-height: {int(height)-20}px; }}
|
| 175 |
-
.sec {{ margin: 10px 0 8px; font-size: 14px; letter-spacing:.3px; color:var(--muted); text-transform:uppercase; }}
|
| 176 |
-
.soap-claim {{ position: relative; margin: 8px 0; padding: 8px 10px 8px 26px; border-radius: 8px; }}
|
| 177 |
-
.soap-claim.active {{ background: var(--accent-weak); outline: 1px solid var(--accent); }}
|
| 178 |
-
.marker {{ position: absolute; left: 8px; top: 14px; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }}
|
| 179 |
-
.soap-claim.active .marker {{ background: var(--accent); }}
|
| 180 |
-
.claim-card {{ border-bottom: 1px dashed var(--border); padding: 10px 6px; }}
|
| 181 |
-
.claim-title {{ cursor: pointer; font-weight: 600; }}
|
| 182 |
-
.features {{ margin-top: 8px; }}
|
| 183 |
-
.feat {{ display:flex; align-items:center; gap:8px; font-size:13px; margin:3px 0; }}
|
| 184 |
-
.bar {{ flex:1; height:8px; background:var(--border); border-radius:4px; overflow:hidden; }}
|
| 185 |
-
.fill {{ height:100%; background:var(--accent); }}
|
| 186 |
-
.refs {{ margin-top: 6px; display:flex; flex-wrap:wrap; gap:6px; }}
|
| 187 |
-
.ref {{ padding:3px 6px; background: var(--accent-weak); border:1px solid var(--border); border-radius:6px; cursor:pointer; }}
|
| 188 |
-
.ref:hover {{ border-color: var(--accent); }}
|
| 189 |
-
dialog#evidence {{ border: 1px solid var(--border); border-radius: 10px; background: var(--bg); color: var(--fg); max-width: 680px; }}
|
| 190 |
-
dialog::backdrop {{ background: rgba(0,0,0,.45); }}
|
| 191 |
-
.ev-title {{ font-weight:600; margin-bottom:6px; }}
|
| 192 |
-
.ev-body {{ white-space: pre-wrap; line-height:1.4; }}
|
| 193 |
-
.kbd {{ font: 12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono"; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 2px 6px; color: var(--muted); }}
|
| 194 |
-
</style>
|
| 195 |
-
|
| 196 |
-
<div class="grid">
|
| 197 |
-
<div class="panel" id="soap-pane">
|
| 198 |
-
<div class="sec" style="margin-top:0;">SOAP — Assessment & Plan</div>
|
| 199 |
-
{left_claims_html}
|
| 200 |
-
</div>
|
| 201 |
-
<div class="panel" id="reason-pane">
|
| 202 |
-
<div style="display:flex;justify-content:space-between;align-items:center;">
|
| 203 |
-
<div class="sec" style="margin:0;">REASONING PANEL</div>
|
| 204 |
-
<div class="kbd" title="Keyboard shortcuts">↑/↓ select • Esc close modal</div>
|
| 205 |
-
</div>
|
| 206 |
-
<div id="claims"></div>
|
| 207 |
-
</div>
|
| 208 |
-
</div>
|
| 209 |
-
|
| 210 |
-
<dialog id="evidence">
|
| 211 |
-
<div class="ev-title"></div>
|
| 212 |
-
<div class="ev-body"></div>
|
| 213 |
-
<div style="margin-top:10px;display:flex;justify-content:flex-end;">
|
| 214 |
-
<button id="closeEv" class="ref">Close (Esc)</button>
|
| 215 |
-
</div>
|
| 216 |
-
</dialog>
|
| 217 |
-
|
| 218 |
-
<script>
|
| 219 |
-
const data = {data_js};
|
| 220 |
-
const allRefs = {refs_js};
|
| 221 |
-
const claimsEl = document.getElementById('claims');
|
| 222 |
-
const soap = document.getElementById('soap-pane');
|
| 223 |
-
const evidence = document.getElementById('evidence');
|
| 224 |
-
const evTitle = evidence.querySelector('.ev-title');
|
| 225 |
-
const evBody = evidence.querySelector('.ev-body');
|
| 226 |
-
const closeEv = document.getElementById('closeEv');
|
| 227 |
-
|
| 228 |
-
function pct(n) {{ return Math.round(n * 100); }}
|
| 229 |
-
|
| 230 |
-
function render() {{
|
| 231 |
-
claimsEl.innerHTML = '';
|
| 232 |
-
data.forEach((d, idx) => {{
|
| 233 |
-
const card = document.createElement('div');
|
| 234 |
-
card.className = 'claim-card';
|
| 235 |
-
const title = document.createElement('div');
|
| 236 |
-
title.className = 'claim-title';
|
| 237 |
-
title.textContent = d.claim || `Claim ${idx+1}`;
|
| 238 |
-
title.addEventListener('click', () => activate(idx, true));
|
| 239 |
-
card.appendChild(title);
|
| 240 |
-
|
| 241 |
-
const feats = document.createElement('div');
|
| 242 |
-
feats.className = 'features';
|
| 243 |
-
(d.features || []).forEach(f => {{
|
| 244 |
-
const row = document.createElement('div'); row.className = 'feat';
|
| 245 |
-
const name = document.createElement('div'); name.textContent = f.token;
|
| 246 |
-
const bar = document.createElement('div'); bar.className = 'bar';
|
| 247 |
-
const fill = document.createElement('div'); fill.className = 'fill'; fill.style.width = (pct(f.weight)) + '%';
|
| 248 |
-
const perc = document.createElement('div'); perc.textContent = (pct(f.weight)) + '%';
|
| 249 |
-
bar.appendChild(fill);
|
| 250 |
-
row.appendChild(name); row.appendChild(bar); row.appendChild(perc);
|
| 251 |
-
feats.appendChild(row);
|
| 252 |
-
}});
|
| 253 |
-
card.appendChild(feats);
|
| 254 |
-
|
| 255 |
-
const refs = document.createElement('div');
|
| 256 |
-
refs.className = 'refs';
|
| 257 |
-
(d.refs || []).forEach(r => {{
|
| 258 |
-
const b = document.createElement('button');
|
| 259 |
-
b.className = 'ref';
|
| 260 |
-
b.textContent = r.label || 'Reference';
|
| 261 |
-
b.title = 'Open evidence snippet';
|
| 262 |
-
b.addEventListener('click', () => openEvidence(r));
|
| 263 |
-
refs.appendChild(b);
|
| 264 |
-
}});
|
| 265 |
-
card.appendChild(refs);
|
| 266 |
-
|
| 267 |
-
claimsEl.appendChild(card);
|
| 268 |
-
}});
|
| 269 |
-
}}
|
| 270 |
-
|
| 271 |
-
function openEvidence(ref) {{
|
| 272 |
-
evTitle.textContent = ref.label || 'Evidence';
|
| 273 |
-
evBody.textContent = ref.snippet || 'Snippet not available for this reference.';
|
| 274 |
-
evidence.showModal();
|
| 275 |
-
}}
|
| 276 |
-
closeEv.addEventListener('click', () => evidence.close());
|
| 277 |
-
document.addEventListener('keydown', (e) => {{
|
| 278 |
-
if (e.key === 'Escape') evidence.close();
|
| 279 |
-
if (['ArrowDown','ArrowUp'].includes(e.key)) {{
|
| 280 |
-
e.preventDefault();
|
| 281 |
-
navigate(e.key === 'ArrowDown' ? 1 : -1);
|
| 282 |
-
}}
|
| 283 |
-
}});
|
| 284 |
-
|
| 285 |
-
function activate(idx, scroll) {{
|
| 286 |
-
// Highlight claim in left pane
|
| 287 |
-
const claimEls = soap.querySelectorAll('.soap-claim');
|
| 288 |
-
claimEls.forEach(el => el.classList.remove('active'));
|
| 289 |
-
const el = claimEls[idx];
|
| 290 |
-
if (el) {{
|
| 291 |
-
el.classList.add('active');
|
| 292 |
-
if (scroll) el.scrollIntoView({{behavior:'smooth', block:'center'}});
|
| 293 |
-
}}
|
| 294 |
-
// Highlight card title (optional)
|
| 295 |
-
const cardTitles = document.querySelectorAll('.claim-title');
|
| 296 |
-
cardTitles.forEach((t, i) => t.style.color = (i===idx ? 'var(--accent)' : 'inherit'));
|
| 297 |
-
window.__rpv4_index = idx;
|
| 298 |
-
}}
|
| 299 |
-
|
| 300 |
-
function navigate(delta) {{
|
| 301 |
-
const max = (data || []).length;
|
| 302 |
-
const curr = (window.__rpv4_index || 0);
|
| 303 |
-
const next = Math.max(0, Math.min(max-1, curr + delta));
|
| 304 |
-
activate(next, true);
|
| 305 |
-
}}
|
| 306 |
-
|
| 307 |
-
render();
|
| 308 |
-
// Initial activation
|
| 309 |
-
activate(0, false);
|
| 310 |
-
</script>
|
| 311 |
-
</div>
|
| 312 |
-
"""
|
| 313 |
-
components.html(html_code, height=height, scrolling=True)
|
|
|
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
from typing import List, Dict, Any, Tuple
|
| 20 |
+
import json, html, os
|
| 21 |
|
| 22 |
import streamlit as st
|
| 23 |
import streamlit.components.v1 as components
|
|
|
|
| 30 |
from .rag_index import search_index
|
| 31 |
from .paths import faiss_index_dir
|
| 32 |
|
| 33 |
+
# Use the same embed model as the FAISS index. You told me it's e5-base-v2.
|
| 34 |
+
SAFE_EMBED_MODEL = os.getenv("RAG_EMBED_MODEL", "intfloat/e5-base-v2")
|
| 35 |
+
|
| 36 |
|
| 37 |
def _normalize_features(pairs: List[Tuple[str, float]], top_k: int = 5) -> List[Dict[str, float]]:
|
| 38 |
# keep top_k and L1-normalize
|
|
|
|
| 47 |
Heuristic snippet lookup: query FAISS by the claim text and preferentially
|
| 48 |
return a chunk from the requested doc/page. Falls back gracefully.
|
| 49 |
"""
|
| 50 |
+
# Guard: if we don't have a doc label or claim text, skip snippet lookup.
|
| 51 |
+
if not claim or not doc:
|
| 52 |
+
return ""
|
| 53 |
+
|
| 54 |
try:
|
|
|
|
| 55 |
results = search_index(
|
| 56 |
faiss_index_dir(),
|
| 57 |
claim,
|
| 58 |
top_k=top_k,
|
| 59 |
+
embed_model=SAFE_EMBED_MODEL,
|
| 60 |
device="cpu",
|
| 61 |
)
|
| 62 |
except TypeError:
|
|
|
|
| 65 |
faiss_index_dir(),
|
| 66 |
claim,
|
| 67 |
top_k=top_k,
|
| 68 |
+
embed_model=SAFE_EMBED_MODEL,
|
| 69 |
)
|
| 70 |
+
except Exception:
|
| 71 |
+
# Index missing / incompatible — fail closed without breaking UI
|
| 72 |
+
return ""
|
| 73 |
|
| 74 |
# exact doc+page match
|
| 75 |
for r in results or []:
|
|
|
|
| 90 |
return ""
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
| 93 |
def build_panel_data(
|
| 94 |
assessment: str,
|
| 95 |
plan: str,
|
|
|
|
| 99 |
mode: str = "sim",
|
| 100 |
) -> List[Dict[str, Any]]:
|
| 101 |
text_for_claims = (assessment or "").strip() + (" " if assessment and plan else "") + (plan or "").strip()
|
| 102 |
+
if not text_for_claims:
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
claims = segment_claims(text_for_claims)
|
| 106 |
+
if not claims:
|
| 107 |
+
return []
|
| 108 |
+
|
| 109 |
citation_idx = build_citation_index(citations or [])
|
| 110 |
exp = explain_claims_sim_only(claims, pcp_summary or "", specialty or "")
|
| 111 |
+
# NOTE: attention re-rank lands in Iteration 3; here we keep 'sim' mode.
|
| 112 |
|
| 113 |
panel: List[Dict[str, Any]] = []
|
| 114 |
for e in exp:
|
| 115 |
+
claim = (e.get("claim") or "").strip()
|
| 116 |
+
if not claim:
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
# Merge guideline + pcp token weights (max-pool then normalize)
|
| 120 |
+
weights: Dict[str, float] = {}
|
| 121 |
for tok, w in (e.get("top_tokens_guideline") or []):
|
| 122 |
+
t = (tok or "").strip()
|
| 123 |
+
if not t:
|
| 124 |
continue
|
| 125 |
+
weights[t] = max(weights.get(t, 0.0), float(w or 0.0))
|
| 126 |
for tok, w in (e.get("top_tokens_pcp") or []):
|
| 127 |
+
t = (tok or "").strip()
|
| 128 |
+
if not t:
|
| 129 |
continue
|
| 130 |
+
weights[t] = max(weights.get(t, 0.0), float(w or 0.0))
|
| 131 |
+
|
| 132 |
features = _normalize_features(list(weights.items()), top_k=5)
|
| 133 |
|
| 134 |
# Map refs -> label + snippet
|
| 135 |
refs_out: List[Dict[str, Any]] = []
|
| 136 |
for r in (e.get("guideline_refs") or []):
|
| 137 |
doc = r.get("doc")
|
| 138 |
+
try:
|
| 139 |
+
page = int(r.get("page", 0))
|
| 140 |
+
except Exception:
|
| 141 |
+
page = 0
|
| 142 |
num = citation_idx.get((doc, page))
|
| 143 |
+
if doc:
|
| 144 |
+
label = f"{doc} [{num}]" if num else f"{doc} (p.{page})"
|
| 145 |
+
else:
|
| 146 |
+
label = f"Reference (p.{page})"
|
| 147 |
snippet = _best_snippet_for_ref(claim, doc, page)
|
| 148 |
refs_out.append({"id": f"{doc}-{page}", "label": label, "snippet": snippet})
|
| 149 |
+
|
| 150 |
panel.append({"claim": claim, "features": features, "refs": refs_out})
|
| 151 |
+
|
| 152 |
return panel
|
| 153 |
|
| 154 |
|
| 155 |
def _escape_script_json(d: Any) -> str:
|
| 156 |
s = json.dumps(d, ensure_ascii=False)
|
| 157 |
+
return s.replace("</",
|
| 158 |
|
| 159 |
+
|
| 160 |
+
|
| 161 |
|
| 162 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|