| """Nursing Knowledge Base β LM Wiki for Nursing Education. |
| |
| Inspired by Karpathy's LLM Wiki pattern, adapted for the Nursing Citizen Development Organisation. |
| Students and educators add raw sources; Claude builds and maintains a structured nursing wiki. |
| """ |
|
|
| import streamlit as st |
| import anthropic |
| import json |
| import zipfile |
| import io |
| import datetime |
| import re |
| import sys |
| import os |
|
|
| try: |
| from pypdf import PdfReader |
| _PDF_AVAILABLE = True |
| except ImportError: |
| _PDF_AVAILABLE = False |
|
|
| import requests as _requests |
|
|
| sys.path.insert(0, os.path.dirname(__file__)) |
| from wiki.starter import get_starter_wiki |
| from core.compiler import compile_source, rebuild_index |
| from core.qa import answer_question, file_answer_to_wiki |
| from core.linter import lint_wiki, generate_missing_article |
|
|
| |
| st.set_page_config( |
| page_title="Nursing Knowledge Base | CQAI", |
| page_icon="π", |
| layout="wide", |
| initial_sidebar_state="expanded", |
| ) |
|
|
| |
| st.markdown(""" |
| <style> |
| /* Main palette */ |
| :root { |
| --nhs-blue: #003087; |
| --nhs-light-blue: #005EB8; |
| --nhs-green: #007F3B; |
| --nhs-warm-yellow: #FFB81C; |
| --nhs-pale-grey: #F0F4F5; |
| } |
| .stApp { background: #F8FAFC; } |
| .article-card { |
| background: white; |
| border-radius: 8px; |
| padding: 1rem 1.2rem; |
| border-left: 4px solid #005EB8; |
| margin-bottom: 0.5rem; |
| cursor: pointer; |
| } |
| .article-card:hover { border-left-color: #007F3B; background: #F0F4F5; } |
| .category-badge { |
| display: inline-block; |
| background: #005EB8; |
| color: white; |
| padding: 2px 8px; |
| border-radius: 12px; |
| font-size: 0.75rem; |
| margin-right: 4px; |
| } |
| .tag-badge { |
| display: inline-block; |
| background: #F0F4F5; |
| color: #333; |
| padding: 2px 6px; |
| border-radius: 10px; |
| font-size: 0.7rem; |
| margin: 1px; |
| } |
| .disclaimer { |
| background: #FFF4CC; |
| border: 1px solid #FFB81C; |
| border-radius: 6px; |
| padding: 8px 12px; |
| font-size: 0.85rem; |
| color: #5A4B00; |
| margin: 8px 0; |
| } |
| .stat-card { |
| background: white; |
| border-radius: 8px; |
| padding: 12px; |
| text-align: center; |
| border: 1px solid #E0E8F0; |
| } |
| .stat-number { font-size: 1.8rem; font-weight: 700; color: #005EB8; } |
| .stat-label { font-size: 0.8rem; color: #666; } |
| .issue-high { border-left: 3px solid #D93025; padding: 8px; margin: 4px 0; background: #FEF3F2; border-radius: 4px; } |
| .issue-medium { border-left: 3px solid #FFB81C; padding: 8px; margin: 4px 0; background: #FFFBF0; border-radius: 4px; } |
| .issue-low { border-left: 3px solid #007F3B; padding: 8px; margin: 4px 0; background: #F0FAF4; border-radius: 4px; } |
| .wiki-title { |
| font-size: 1.6rem; |
| font-weight: 700; |
| color: #003087; |
| margin-bottom: 0.2rem; |
| } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| if "wiki" not in st.session_state: |
| st.session_state.wiki = get_starter_wiki() |
| if "selected_article" not in st.session_state: |
| st.session_state.selected_article = None |
| if "qa_history" not in st.session_state: |
| st.session_state.qa_history = [] |
| if "lint_report" not in st.session_state: |
| st.session_state.lint_report = None |
| if "compile_status" not in st.session_state: |
| st.session_state.compile_status = "" |
|
|
| wiki = st.session_state.wiki |
|
|
|
|
| def get_client() -> anthropic.Anthropic | None: |
| key = st.session_state.get("api_key", "").strip() |
| if not key: |
| return None |
| return anthropic.Anthropic(api_key=key) |
|
|
|
|
| def log(entry: str): |
| today = datetime.date.today().isoformat() |
| wiki["log"].append(f"## [{today}] {entry}") |
|
|
|
|
| def add_or_update_article(article: dict): |
| slug = article["slug"] |
| wiki["articles"][slug] = { |
| "title": article["title"], |
| "category": article["category"], |
| "tags": article.get("tags", []), |
| "last_updated": article.get("last_updated", datetime.date.today().isoformat()), |
| "sources": article.get("sources", []), |
| "content": article["content"], |
| } |
| wiki["metadata"]["article_count"] = len(wiki["articles"]) |
|
|
|
|
| def fetch_pdf_from_url(url: str, timeout: int = 60) -> bytes: |
| """Fetch a PDF from a URL server-side (bypasses HF proxy upload limits).""" |
| headers = {"User-Agent": "NursingKnowledgeBase/1.0 (nursing education tool)"} |
| resp = _requests.get(url, headers=headers, timeout=timeout, stream=True) |
| resp.raise_for_status() |
| return resp.content |
|
|
|
|
| def extract_pdf_text(file_bytes: bytes) -> tuple[str, int]: |
| """Extract all text from a PDF. Returns (text, page_count).""" |
| reader = PdfReader(io.BytesIO(file_bytes)) |
| pages = [] |
| for i, page in enumerate(reader.pages): |
| text = page.extract_text() or "" |
| if text.strip(): |
| pages.append(f"--- Page {i + 1} ---\n{text}") |
| return "\n\n".join(pages), len(reader.pages) |
|
|
|
|
| def export_wiki_zip() -> bytes: |
| buf = io.BytesIO() |
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: |
| |
| zf.writestr("wiki/index.md", wiki.get("index_summary", "")) |
| |
| zf.writestr("wiki/log.md", "\n\n".join(wiki.get("log", []))) |
| |
| for slug, art in wiki["articles"].items(): |
| category = art.get("category", "general") |
| zf.writestr(f"wiki/{category}/{slug}.md", art["content"]) |
| |
| for src_id, src in wiki.get("sources", {}).items(): |
| zf.writestr(f"raw/{src_id}.md", f"# {src['title']}\n\n{src['content']}") |
| |
| zf.writestr("wiki_backup.json", json.dumps(wiki, indent=2)) |
| buf.seek(0) |
| return buf.getvalue() |
|
|
|
|
| def category_color(cat: str) -> str: |
| colors = { |
| "standards": "#003087", "clinical": "#007F3B", "pharmacology": "#8B0000", |
| "evidence": "#6B21A8", "frameworks": "#0369A1", "safety": "#C05621", |
| "law": "#1F2937", "mental_health": "#065F46", "research": "#4338CA", |
| "ethics": "#92400E", |
| } |
| return colors.get(cat, "#005EB8") |
|
|
|
|
| |
| with st.sidebar: |
| st.markdown('<div class="wiki-title">π Nursing Wiki</div>', unsafe_allow_html=True) |
| st.caption("Nursing Citizen Development Organisation") |
| st.divider() |
|
|
| |
| with st.expander("π Claude API Key (BYOK)", expanded=not st.session_state.get("api_key")): |
| api_key = st.text_input( |
| "Anthropic API Key", |
| type="password", |
| value=st.session_state.get("api_key", ""), |
| help="Required for Compile, Q&A, and Lint features. Never stored β session only.", |
| key="api_key_input", |
| ) |
| if api_key != st.session_state.get("api_key", ""): |
| st.session_state.api_key = api_key |
| st.rerun() |
| if st.session_state.get("api_key"): |
| st.success("API key set") |
| else: |
| st.info("Enter key to enable AI features") |
|
|
| st.divider() |
|
|
| |
| st.markdown("**Wiki Statistics**") |
| articles = wiki["articles"] |
| sources = wiki.get("sources", {}) |
| categories = {} |
| for art in articles.values(): |
| cat = art.get("category", "other") |
| categories[cat] = categories.get(cat, 0) + 1 |
|
|
| col1, col2 = st.columns(2) |
| with col1: |
| st.markdown(f'<div class="stat-card"><div class="stat-number">{len(articles)}</div><div class="stat-label">Articles</div></div>', unsafe_allow_html=True) |
| with col2: |
| st.markdown(f'<div class="stat-card"><div class="stat-number">{len(sources)}</div><div class="stat-label">Sources</div></div>', unsafe_allow_html=True) |
|
|
| st.markdown("**Categories**") |
| for cat, count in sorted(categories.items(), key=lambda x: -x[1]): |
| color = category_color(cat) |
| st.markdown(f'<span style="color:{color}">β</span> {cat.replace("_", " ").title()}: **{count}**', unsafe_allow_html=True) |
|
|
| st.divider() |
|
|
| |
| st.markdown("**Import / Export**") |
| uploaded = st.file_uploader("Import wiki (JSON)", type="json", key="wiki_import") |
| if uploaded: |
| try: |
| imported = json.load(uploaded) |
| if "articles" in imported: |
| st.session_state.wiki = imported |
| st.success(f"Imported {len(imported['articles'])} articles") |
| st.rerun() |
| except Exception as e: |
| st.error(f"Import failed: {e}") |
|
|
| zip_bytes = export_wiki_zip() |
| st.download_button( |
| "π₯ Export Wiki (ZIP)", |
| data=zip_bytes, |
| file_name=f"nursing_wiki_{datetime.date.today()}.zip", |
| mime="application/zip", |
| use_container_width=True, |
| ) |
|
|
| json_bytes = json.dumps(wiki, indent=2).encode() |
| st.download_button( |
| "πΎ Save Wiki (JSON)", |
| data=json_bytes, |
| file_name=f"nursing_wiki_{datetime.date.today()}.json", |
| mime="application/json", |
| use_container_width=True, |
| ) |
|
|
| |
| tab_browse, tab_sources, tab_compile, tab_qa, tab_lint, tab_log = st.tabs([ |
| "π Browse Wiki", |
| "β Add Sources", |
| "π¨ Compile", |
| "π¬ Ask", |
| "π Health Check", |
| "π Log", |
| ]) |
|
|
| |
| |
| |
| with tab_browse: |
| st.markdown('<div class="disclaimer">π This wiki supports nursing education and CPD. It does not replace clinical judgment, current NMC guidance, or local trust policies.</div>', unsafe_allow_html=True) |
|
|
| col_list, col_reader = st.columns([1, 2]) |
|
|
| with col_list: |
| st.markdown("### Articles") |
|
|
| |
| search = st.text_input("π Search", placeholder="e.g. ABCDE, medications, safeguarding") |
|
|
| |
| all_cats = sorted(set(a.get("category", "other") for a in wiki["articles"].values())) |
| cat_filter = st.selectbox("Category", ["All"] + [c.replace("_", " ").title() for c in all_cats]) |
|
|
| |
| filtered = {} |
| for slug, art in wiki["articles"].items(): |
| cat_match = cat_filter == "All" or art.get("category", "").replace("_", " ").title() == cat_filter |
| if search: |
| text = (art["title"] + " " + " ".join(art.get("tags", []))).lower() |
| search_match = all(term in text for term in search.lower().split()) |
| else: |
| search_match = True |
| if cat_match and search_match: |
| filtered[slug] = art |
|
|
| |
| sorted_articles = sorted(filtered.items(), key=lambda x: (x[1].get("category", ""), x[1]["title"])) |
|
|
| |
| current_cat = None |
| for slug, art in sorted_articles: |
| cat = art.get("category", "other") |
| if cat != current_cat: |
| current_cat = cat |
| color = category_color(cat) |
| st.markdown(f'<div style="margin-top:12px;margin-bottom:4px;font-weight:600;color:{color};font-size:0.85rem;text-transform:uppercase;letter-spacing:0.05em">{cat.replace("_"," ")}</div>', unsafe_allow_html=True) |
|
|
| if st.button( |
| art["title"], |
| key=f"art_{slug}", |
| use_container_width=True, |
| type="secondary" if st.session_state.selected_article != slug else "primary", |
| ): |
| st.session_state.selected_article = slug |
| st.rerun() |
|
|
| st.caption(f"{len(filtered)} of {len(wiki['articles'])} articles") |
|
|
| with col_reader: |
| if st.session_state.selected_article and st.session_state.selected_article in wiki["articles"]: |
| art = wiki["articles"][st.session_state.selected_article] |
| slug = st.session_state.selected_article |
|
|
| |
| col_title, col_meta = st.columns([3, 1]) |
| with col_title: |
| st.markdown(f"## {art['title']}") |
| with col_meta: |
| color = category_color(art.get("category", "")) |
| st.markdown(f'<span class="category-badge" style="background:{color}">{art.get("category", "").replace("_", " ")}</span>', unsafe_allow_html=True) |
| st.caption(f"Updated: {art.get('last_updated', 'n/a')}") |
|
|
| |
| tags_html = " ".join([f'<span class="tag-badge">{t}</span>' for t in art.get("tags", [])]) |
| st.markdown(tags_html, unsafe_allow_html=True) |
|
|
| st.divider() |
|
|
| |
| content = art["content"] |
| content = re.sub(r'\[\[([^\]]+)\]\]', r'**\1**', content) |
| st.markdown(content) |
|
|
| st.divider() |
| |
| st.download_button( |
| "π Download article (.md)", |
| data=art["content"].encode(), |
| file_name=f"{slug}.md", |
| mime="text/markdown", |
| ) |
|
|
| |
| with st.expander("βοΈ Edit this article"): |
| new_content = st.text_area("Content (markdown)", value=art["content"], height=400, key=f"edit_{slug}") |
| if st.button("Save changes", key=f"save_{slug}"): |
| wiki["articles"][slug]["content"] = new_content |
| wiki["articles"][slug]["last_updated"] = datetime.date.today().isoformat() |
| log(f"edit | {art['title']} β manually edited") |
| st.success("Saved") |
| st.rerun() |
| else: |
| st.markdown("### Welcome to the Nursing Knowledge Base") |
| st.markdown(""" |
| This wiki is a living knowledge base for nursing education, powered by Claude AI. |
| |
| **Getting started:** |
| 1. **Browse** articles using the list on the left β click any article to read it |
| 2. **Add Sources** β paste guidelines, articles, or clinical notes |
| 3. **Compile** β Claude integrates your sources into the wiki |
| 4. **Ask** β ask nursing questions; Claude answers from the wiki |
| 5. **Health Check** β audit the wiki for gaps, contradictions, and suggestions |
| |
| **Pre-loaded content** covers: |
| - NMC Code & Proficiency Standards 2018 |
| - ABCDE Assessment and NEWS2 |
| - Drug calculations and the Nine Rights |
| - PICO and Evidence-Based Practice |
| - Person-Centred Care and the Six Cs |
| - Mental Capacity Act 2005 |
| - Safeguarding Adults and Children |
| - Infection Prevention and Control |
| - Duty of Candour |
| |
| *Select an article on the left to begin reading.* |
| """) |
| st.markdown(f'<div class="disclaimer">This tool supports but does not replace clinical judgment. Always refer to current NMC guidelines, your local trust policy, and senior clinical colleagues.</div>', unsafe_allow_html=True) |
|
|
|
|
| |
| |
| |
| with tab_sources: |
| st.markdown("### Add Raw Sources") |
| st.markdown(""" |
| Add source material to the wiki. Claude will integrate it when you run **Compile**. |
| |
| Suitable sources include NICE clinical guidelines, NMC documents, NHS trust protocols, |
| research papers, textbook chapters, or clinical audit findings β as **PDF or pasted text**. |
| Large PDFs (100+ pages) are supported; text is extracted from every page automatically. |
| """) |
|
|
| col_add, col_list_src = st.columns([1, 1]) |
|
|
| with col_add: |
| st.markdown("#### Add New Source") |
| src_title = st.text_input("Source title", placeholder="e.g. NICE NG51 β Sepsis (2016)") |
| src_type = st.selectbox("Type", ["Clinical Guideline", "Research Paper", "NMC Document", "NHS Protocol", "Textbook", "Other"]) |
|
|
| input_method = st.radio( |
| "Input method", |
| ["PDF from URL", "Upload PDF", "Paste text"], |
| horizontal=True, |
| help="Use 'PDF from URL' for large files β the server fetches it directly.", |
| ) |
|
|
| src_content = "" |
| pdf_meta = None |
|
|
| if input_method == "PDF from URL": |
| st.caption("Paste a direct link to any PDF β NICE guidelines, NMC documents, research papers, etc. The server fetches it, so there is no size limit.") |
| pdf_url = st.text_input( |
| "PDF URL", |
| placeholder="https://www.nice.org.uk/guidance/ng51/resources/sepsis-pdf-...", |
| key="pdf_url", |
| ) |
| if pdf_url and st.button("Fetch & Extract", key="fetch_pdf"): |
| with st.spinner("Fetching PDF from URL..."): |
| try: |
| raw_bytes = fetch_pdf_from_url(pdf_url) |
| extracted, page_count = extract_pdf_text(raw_bytes) |
| src_content = extracted |
| pdf_meta = {"pages": page_count, "size_kb": len(raw_bytes) // 1024} |
| st.session_state["fetched_pdf_content"] = extracted |
| st.session_state["fetched_pdf_meta"] = pdf_meta |
| st.success(f"Fetched {page_count} pages / {len(extracted):,} characters") |
| with st.expander("Preview extracted text"): |
| st.text(extracted[:1500] + ("..." if len(extracted) > 1500 else "")) |
| except Exception as e: |
| st.error(f"Fetch failed: {e}") |
| |
| if not src_content and st.session_state.get("fetched_pdf_content"): |
| src_content = st.session_state["fetched_pdf_content"] |
| pdf_meta = st.session_state.get("fetched_pdf_meta") |
|
|
| elif input_method == "Upload PDF": |
| if not _PDF_AVAILABLE: |
| st.error("pypdf not installed β PDF upload unavailable.") |
| else: |
| st.caption("For large PDFs (>50 MB) use 'PDF from URL' instead β HF Spaces limits browser uploads.") |
| uploaded_pdf = st.file_uploader( |
| "Upload PDF", |
| type=["pdf"], |
| key="pdf_upload", |
| ) |
| if uploaded_pdf is not None: |
| with st.spinner(f"Extracting text from {uploaded_pdf.name}..."): |
| raw_bytes = uploaded_pdf.read() |
| try: |
| extracted, page_count = extract_pdf_text(raw_bytes) |
| src_content = extracted |
| pdf_meta = {"pages": page_count, "size_kb": len(raw_bytes) // 1024} |
| st.success(f"Extracted {page_count} pages / {len(extracted):,} characters") |
| with st.expander("Preview extracted text"): |
| st.text(extracted[:1500] + ("..." if len(extracted) > 1500 else "")) |
| except Exception as e: |
| st.error(f"PDF extraction failed: {e}") |
| if not src_title and uploaded_pdf: |
| src_title = uploaded_pdf.name.replace(".pdf", "").replace("_", " ") |
|
|
| else: |
| src_content = st.text_area( |
| "Paste text here", |
| height=300, |
| placeholder="Paste the full text of the guideline, paper, or document here...", |
| ) |
|
|
| if st.button("β Add Source", type="primary", disabled=not (src_title and src_content)): |
| src_id = f"src_{len(wiki.get('sources', {})) + 1:04d}" |
| if "sources" not in wiki: |
| wiki["sources"] = {} |
| entry = { |
| "title": src_title, |
| "type": src_type, |
| "content": src_content, |
| "added": datetime.date.today().isoformat(), |
| "processed": False, |
| } |
| if pdf_meta: |
| entry["pdf_pages"] = pdf_meta["pages"] |
| entry["pdf_size_kb"] = pdf_meta["size_kb"] |
| wiki["sources"][src_id] = entry |
| log(f"ingest | Added source: {src_title} ({len(src_content):,} chars)") |
| st.session_state.pop("fetched_pdf_content", None) |
| st.session_state.pop("fetched_pdf_meta", None) |
| st.success(f"Source added: **{src_title}**") |
| st.rerun() |
|
|
| with col_list_src: |
| st.markdown("#### Sources") |
| sources = wiki.get("sources", {}) |
|
|
| if not sources: |
| st.info("No sources added yet. Add your first source on the left.") |
| else: |
| for src_id, src in sources.items(): |
| status = "β
Compiled" if src.get("processed") else "β³ Pending compile" |
| with st.expander(f"{src['title']} β {status}"): |
| st.caption(f"Type: {src['type']} | Added: {src['added']}") |
| st.text(src["content"][:400] + ("..." if len(src["content"]) > 400 else "")) |
| if st.button("ποΈ Remove", key=f"del_{src_id}"): |
| del wiki["sources"][src_id] |
| log(f"delete | Removed source: {src['title']}") |
| st.rerun() |
|
|
|
|
| |
| |
| |
| with tab_compile: |
| st.markdown("### Compile Wiki") |
| st.markdown(""" |
| Claude reads your raw sources and integrates them into the wiki β updating existing articles, |
| creating new ones, adding cross-references, and keeping the index current. |
| |
| This is the core Karpathy pattern: **you add sources, Claude maintains the knowledge base.** |
| """) |
|
|
| client = get_client() |
| if not client: |
| st.warning("Enter your Anthropic API key in the sidebar to use Compile.") |
| else: |
| pending = {sid: s for sid, s in wiki.get("sources", {}).items() if not s.get("processed")} |
|
|
| if not pending: |
| st.info("No pending sources. Add sources in the **Add Sources** tab, then compile.") |
| else: |
| st.markdown(f"**{len(pending)} source(s) ready to compile:**") |
| for src_id, src in pending.items(): |
| st.markdown(f"- {src['title']} ({src['type']})") |
|
|
| model = st.selectbox("Model", ["claude-sonnet-4-6", "claude-opus-4-6"], |
| help="Sonnet is faster and cheaper; Opus produces richer articles.") |
|
|
| if st.button("π¨ Compile Now", type="primary"): |
| progress = st.progress(0) |
| status = st.empty() |
| results_container = st.container() |
|
|
| for i, (src_id, src) in enumerate(pending.items()): |
| char_count = len(src["content"]) |
| chunk_note = f" β {char_count:,} chars, will chunk" if char_count > 7000 else "" |
| status.markdown(f"βοΈ Compiling: **{src['title']}** ({i+1}/{len(pending)}){chunk_note}...") |
| try: |
| result = compile_source( |
| client=client, |
| source_title=src["title"], |
| source_content=src["content"], |
| existing_index=wiki.get("index_summary", ""), |
| existing_articles=wiki["articles"], |
| model=model, |
| ) |
|
|
| updated_count = len(result.get("articles_updated", [])) |
| created_count = len(result.get("articles_created", [])) |
|
|
| for art in result.get("articles_updated", []): |
| add_or_update_article(art) |
| for art in result.get("articles_created", []): |
| add_or_update_article(art) |
|
|
| wiki["sources"][src_id]["processed"] = True |
| log(f"compile | {src['title']} β updated {updated_count} articles, created {created_count} new articles") |
|
|
| with results_container: |
| st.success(f"β
**{src['title']}**: {updated_count} updated, {created_count} created") |
| if result.get("summary"): |
| st.caption(result["summary"]) |
|
|
| except Exception as e: |
| with results_container: |
| st.error(f"β Failed to compile {src['title']}: {e}") |
|
|
| progress.progress((i + 1) / len(pending)) |
|
|
| |
| status.markdown("π Rebuilding wiki index...") |
| try: |
| new_index = rebuild_index(client, wiki["articles"], model=model) |
| wiki["index_summary"] = new_index |
| log(f"index | Wiki index rebuilt β {len(wiki['articles'])} articles") |
| except Exception as e: |
| st.warning(f"Index rebuild failed: {e}") |
|
|
| status.markdown("β
Compilation complete!") |
| progress.progress(1.0) |
| st.rerun() |
|
|
| |
| st.divider() |
| st.markdown("**Rebuild Index**") |
| st.caption("Regenerate the wiki index from all current articles (useful after manual edits).") |
| if st.button("π Rebuild Index"): |
| if client: |
| with st.spinner("Rebuilding..."): |
| try: |
| new_index = rebuild_index(client, wiki["articles"]) |
| wiki["index_summary"] = new_index |
| log("index | Manual index rebuild") |
| st.success("Index rebuilt") |
| st.text_area("Updated Index", value=new_index, height=300) |
| except Exception as e: |
| st.error(f"Failed: {e}") |
|
|
|
|
| |
| |
| |
| with tab_qa: |
| st.markdown("### Ask the Wiki") |
| st.markdown('<div class="disclaimer">Answers are generated from the wiki content and are for educational use only. This tool does not replace clinical judgment or current NMC/NICE guidelines.</div>', unsafe_allow_html=True) |
|
|
| client = get_client() |
| if not client: |
| st.warning("Enter your Anthropic API key in the sidebar to use Q&A.") |
| else: |
| |
| st.markdown("**Example questions:**") |
| examples = [ |
| "What are the five statutory principles of the Mental Capacity Act?", |
| "How do I calculate an IV drip rate for 1000 mL over 6 hours?", |
| "What does the NMC Code say about delegation?", |
| "What is the NEWS2 threshold for emergency escalation?", |
| "How do I apply the PICO framework to a clinical question about wound care?", |
| ] |
| cols = st.columns(3) |
| for i, ex in enumerate(examples): |
| if cols[i % 3].button(ex, key=f"ex_{i}", use_container_width=True): |
| st.session_state["qa_question"] = ex |
|
|
| |
| question = st.text_area( |
| "Your question", |
| value=st.session_state.get("qa_question", ""), |
| height=100, |
| placeholder="Ask any nursing question...", |
| key="qa_input", |
| ) |
|
|
| model = st.selectbox("Model", ["claude-sonnet-4-6", "claude-opus-4-6"], key="qa_model") |
| col_ask, col_file = st.columns([3, 1]) |
| ask_clicked = col_ask.button("π¬ Ask", type="primary", disabled=not question) |
| file_last = col_file.checkbox("File answer to wiki", value=False, |
| help="Save valuable Q&A answers as new wiki articles") |
|
|
| if ask_clicked and question: |
| with st.spinner("Searching wiki and composing answer..."): |
| try: |
| answer = answer_question(client, question, wiki["articles"], model=model) |
|
|
| |
| st.session_state.qa_history.append({ |
| "question": question, |
| "answer": answer, |
| "timestamp": datetime.datetime.now().isoformat(), |
| }) |
| log(f"query | {question[:80]}") |
|
|
| |
| if file_last: |
| with st.spinner("Filing answer to wiki..."): |
| new_art = file_answer_to_wiki(client, question, answer, model="claude-haiku-4-5-20251001") |
| if new_art: |
| add_or_update_article(new_art) |
| log(f"file | Created article from Q&A: {new_art['title']}") |
| st.success(f"Filed as new article: **{new_art['title']}**") |
|
|
| st.session_state["qa_question"] = "" |
| except Exception as e: |
| st.error(f"Error: {e}") |
|
|
| |
| if st.session_state.qa_history: |
| st.divider() |
| st.markdown("### Recent Questions") |
|
|
| for qa in reversed(st.session_state.qa_history[-10:]): |
| with st.expander(f"β {qa['question'][:80]}{'...' if len(qa['question'])>80 else ''}", expanded=False): |
| st.markdown(qa["answer"]) |
| st.download_button( |
| "π Save answer", |
| data=f"# {qa['question']}\n\n{qa['answer']}".encode(), |
| file_name="answer.md", |
| mime="text/markdown", |
| key=f"dl_{qa['timestamp']}", |
| ) |
|
|
| if st.button("ποΈ Clear history"): |
| st.session_state.qa_history = [] |
| st.rerun() |
|
|
|
|
| |
| |
| |
| with tab_lint: |
| st.markdown("### Wiki Health Check") |
| st.markdown(""" |
| Claude audits the wiki for: |
| - **Contradictions** between articles |
| - **Stale content** that may need updating |
| - **Orphan articles** with few cross-references |
| - **Missing links** between related articles |
| - **Clinical safety gaps** β important missing content |
| - **Suggested new articles** to expand the wiki |
| """) |
|
|
| client = get_client() |
| if not client: |
| st.warning("Enter your Anthropic API key in the sidebar to run a health check.") |
| else: |
| model = st.selectbox("Model", ["claude-sonnet-4-6", "claude-opus-4-6"], key="lint_model") |
|
|
| if st.button("π Run Health Check", type="primary"): |
| with st.spinner("Auditing wiki... this may take a moment"): |
| try: |
| report = lint_wiki(client, wiki["articles"], wiki.get("index_summary", ""), model=model) |
| st.session_state.lint_report = report |
| log(f"lint | Health check completed β {report.get('total_issues', 0)} issues found") |
| except Exception as e: |
| st.error(f"Health check failed: {e}") |
|
|
| report = st.session_state.lint_report |
| if report: |
| st.divider() |
|
|
| |
| health = report.get("overall_health", "Unknown") |
| health_color = {"Good": "#007F3B", "Fair": "#FFB81C", "Needs attention": "#D93025"}.get(health, "#666") |
| st.markdown(f'<h3 style="color:{health_color}">Overall Health: {health}</h3>', unsafe_allow_html=True) |
| st.markdown(report.get("summary", "")) |
|
|
| col_issues, col_suggestions = st.columns([1, 1]) |
|
|
| with col_issues: |
| st.markdown(f"### Issues ({report.get('total_issues', 0)})") |
|
|
| for issue in report.get("issues", []): |
| sev = issue.get("severity", "low") |
| css = f"issue-{sev}" |
| icon = {"high": "π΄", "medium": "π‘", "low": "π’"}.get(sev, "β") |
| st.markdown( |
| f'<div class="{css}"><strong>{icon} {issue.get("type", "").replace("_", " ").title()}</strong> ' |
| f'β {issue.get("article", "wiki-wide")}<br>' |
| f'{issue.get("description", "")}<br>' |
| f'<em>Fix: {issue.get("recommendation", "")}</em></div>', |
| unsafe_allow_html=True, |
| ) |
|
|
| if report.get("strengths"): |
| st.markdown("### Strengths") |
| for s in report["strengths"]: |
| st.markdown(f"β
{s}") |
|
|
| with col_suggestions: |
| st.markdown("### Suggested New Articles") |
| suggested = report.get("suggested_new_articles", []) |
|
|
| if not suggested: |
| st.info("No new articles suggested.") |
| else: |
| for suggestion in suggested: |
| with st.expander(f"π {suggestion['title']} ({suggestion['category']})"): |
| st.caption(suggestion.get("rationale", "")) |
| if suggestion.get("key_topics"): |
| st.markdown("**Key topics**: " + ", ".join(suggestion["key_topics"])) |
|
|
| if st.button(f"Generate article: {suggestion['title'][:30]}...", |
| key=f"gen_{suggestion['title'][:20]}"): |
| with st.spinner(f"Generating: {suggestion['title']}..."): |
| try: |
| new_art = generate_missing_article( |
| client, |
| suggestion["title"], |
| suggestion["category"], |
| suggestion.get("key_topics", []), |
| wiki.get("index_summary", ""), |
| model=model, |
| ) |
| add_or_update_article(new_art) |
| log(f"generate | Created article: {suggestion['title']} (from lint suggestion)") |
| st.success(f"Created: **{new_art['title']}**") |
| st.rerun() |
| except Exception as e: |
| st.error(f"Failed: {e}") |
|
|
|
|
| |
| |
| |
| with tab_log: |
| st.markdown("### Operation Log") |
| st.caption("Append-only chronological record of all wiki operations.") |
|
|
| log_entries = wiki.get("log", []) |
|
|
| if not log_entries: |
| st.info("No log entries yet.") |
| else: |
| |
| for entry in reversed(log_entries): |
| st.markdown(entry) |
|
|
| st.download_button( |
| "π Download Log", |
| data="\n\n".join(log_entries).encode(), |
| file_name=f"wiki_log_{datetime.date.today()}.md", |
| mime="text/markdown", |
| ) |
|
|