DiplomovaPraca/demo/app.py
Oleh Poiasnik 1a5cc72067 Add pharmacy agent and Streamlit web demo
Recognises products by trade name or substance (Slovak inflection aware), queries
LightRAG and shows the answer with the retrieved subgraph and ADC source texts.
2026-11-02 16:14:32 +01:00

368 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Web demo of the pharmacy decision-support agent (Streamlit).
The agent answers questions about indications, contraindications and
interactions using the LightRAG knowledge graph built from ADC leaflets.
Every answer is shown together with its justification: the part of the
knowledge graph that was retrieved and the ADC leaflet texts it came from.
Run (LightRAG server must be running, see start_servers.py):
streamlit run demo/app.py
"""
from __future__ import annotations
import json
import html
import sys
import re
import unicodedata
from collections import Counter, defaultdict
from pathlib import Path
import requests
import streamlit as st
from pyvis.network import Network
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from pharmacy_agent import QUERY_SETTINGS, normalize, build_aliases, detect_drugs, describe_match, prepare_query
PRODUCT_INDEX = ROOT / "outputs" / "demo" / "product_index.json"
DEFAULT_URL = "http://localhost:9621"
MODES = {
"mix": "mix – graf + textové úryvky (odporúčané)",
"hybrid": "hybrid – lokálny + globálny graf",
"local": "local – entity a ich susedia",
"global": "global – vzťahy",
"naive": "naive – len textové úryvky (bez grafu)",
}
EXAMPLES = [
"Môžem užívať Nurofen spolu s warfarínom?",
"Kedy sa nesmie užívať metformín?",
"Na čo sa používa Aclexa?",
"Aké lieky sa nemajú kombinovať s klaritromycínom?",
]
ENTITY_COLORS = {
"drug": "#4C78A8",
"activesubstance": "#72B7B2",
"drugclass": "#B279A2",
"indication": "#54A24B",
"condition": "#F58518",
"contraindication": "#E45756",
"interaction": "#EECA3B",
"patientgroup": "#9D755D",
}
DEFAULT_ENTITY_COLOR = "#BAB0AC"
# Relation keyword -> (label, colour) used to highlight edges in the graph.
RELATION_KINDS = (
("kontraindik", "kontraindikácia", "#E45756"),
("interak", "interakcia", "#F58518"),
("indik", "indikácia", "#54A24B"),
("upozorn", "upozornenie", "#EECA3B"),
)
# Words that start product names but do not identify a product.
# ---------------------------------------------------------------- helpers
def api(base_url: str, method: str, path: str, timeout: int = 300, **kwargs):
response = requests.request(method, f"{base_url}{path}", timeout=timeout, **kwargs)
response.raise_for_status()
return response.json()
@st.cache_data(show_spinner="Načítavam index liekov ADC ...")
def load_product_index() -> list[dict] | None:
if not PRODUCT_INDEX.exists():
return None
return json.loads(PRODUCT_INDEX.read_text(encoding="utf-8"))
def clean(text: str | None, limit: int = 600) -> str:
"""LightRAG joins merged descriptions with <SEP>; show them as paragraphs."""
text = str(text or "").replace("<SEP>", "\n\n")
return text if len(text) <= limit else text[:limit].rsplit(" ", 1)[0] + " …"
def relation_kind(keywords: str, description: str) -> tuple[str, str]:
text = normalize(f"{keywords} {description}")
for needle, label, color in RELATION_KINDS:
if needle in text:
return label, color
return "iný vzťah", "#9E9E9E"
def render_graph(nodes: list[dict], edges: list[dict], highlight: set[str], height: int = 560) -> None:
net = Network(height=f"{height}px", width="100%", bgcolor="#ffffff", font_color="#222222", cdn_resources="in_line")
net.barnes_hut(gravity=-800, central_gravity=0.15, spring_length=100)
for node in nodes:
entity_type = str(node.get("type") or "").lower()
is_focus = normalize(node["id"]) in highlight
net.add_node(
node["id"],
label=node["id"][:40],
title=f"{node['id']} ({entity_type or '?'})\n\n{clean(node.get('description'))}",
color=ENTITY_COLORS.get(entity_type, DEFAULT_ENTITY_COLOR),
size=28 if is_focus else 14,
borderWidth=4 if is_focus else 1,
font={"size":18,"strokeWidth":3,"strokeColor":"#ffffff"},
)
names = {node["id"] for node in nodes}
for edge in edges:
if edge["src"] not in names or edge["tgt"] not in names:
continue
label, color = relation_kind(edge.get("keywords", ""), edge.get("description", ""))
net.add_edge(
edge["src"], edge["tgt"], color=color, width=3 if label != "iný vzťah" else 1,
title=f"{label}: {edge.get('keywords', '')}\n\n{clean(edge.get('description'))}",
)
graph_html = net.generate_html(notebook=False)
graph_html = graph_html.replace("</body>", """<script>
network.once('stabilizationIterationsDone', function () {
network.setOptions({physics: false, nodes: {scaling: {label: {drawThreshold: 0}}}});
network.fit({animation: false});
});
</script></body>""")
st.iframe(graph_html, height=height + 20)
def graph_legend() -> None:
entity_html = " ".join(
f"<span style='background:{c};color:white;padding:2px 6px;border-radius:4px;margin-right:4px'>{t}</span>"
for t, c in ENTITY_COLORS.items()
)
relation_html = " ".join(
f"<span style='border-bottom:3px solid {c};margin-right:10px'>{label}</span>" for _, label, c in RELATION_KINDS
)
st.markdown(f"<small>Uzly: {entity_html}<br>Hrany: {relation_html}</small>", unsafe_allow_html=True)
def product_name(url: str, by_url: dict) -> str:
product = by_url.get(url)
return product["name"] if product else url
# ---------------------------------------------------------------- page
st.set_page_config(page_title="Lekárnický asistent", page_icon="💊", layout="wide")
with st.sidebar:
st.header("Nastavenia")
base_url = st.text_input("LightRAG server", DEFAULT_URL)
mode = st.selectbox("Režim vyhľadávania", list(MODES), format_func=MODES.get)
top_k = st.slider("Počet entít / vzťahov (top_k)", 10, 80, 40, step=5)
max_graph_nodes = st.slider("Max. uzlov v zobrazenom grafe", 10, 120, 50, step=10)
try:
health = api(base_url, "GET", "/health", timeout=5)
config = health.get("configuration") or {}
st.success(f"Server beží · LLM: {config.get('llm_model', '?')}")
except Exception as exc: # noqa: BLE001
st.error(f"Server nedostupný: {exc}")
st.caption(
"Odpovede sú generované jazykovým modelom na základe príbalových letákov ADC "
"a nenahrádzajú konzultáciu s lekárom alebo lekárnikom."
)
products = load_product_index()
if products is None:
st.warning(
f"Chýba index liekov `{PRODUCT_INDEX.relative_to(ROOT)}` – rozpoznávanie obchodných názvov je vypnuté. "
"Vytvorte ho príkazom `python scripts/demo/build_product_index.py`."
)
aliases, by_prefix, by_url = {}, {}, {}
else:
aliases, by_prefix, by_url = build_aliases(products)
st.title("💊 Lekárnický asistent nad znalostným grafom ADC")
st.caption("Indikácie · kontraindikácie · interakcie — odpoveď s odôvodnením zo znalostného grafu a príbalových letákov")
tab_ask, tab_explore = st.tabs(["Otázka", "Prieskum grafu"])
# ---------------------------------------------------------------- tab: question
with tab_ask:
if "question" not in st.session_state:
st.session_state.question = EXAMPLES[0]
cols = st.columns(len(EXAMPLES))
for col, example in zip(cols, EXAMPLES):
if col.button(example, width="stretch"):
st.session_state.question = example
question = st.text_area("Otázka", key="question", height=80)
ask = st.button("Opýtať sa", type="primary")
if ask and question.strip():
prepared = prepare_query(question, products or [], aliases, by_prefix)
matches = prepared["matches"]
query = prepared["query"]
payload = {**QUERY_SETTINGS,"query":query,"mode":mode,"top_k":top_k,
"user_prompt":prepared["user_prompt"]}
try:
with st.spinner("Vyhľadávam v znalostnom grafe ..."):
data = api(base_url, "POST", "/query/data", json=payload)
with st.spinner("Generujem odpoveď ..."):
answer = api(base_url, "POST", "/query", json={**payload, "include_references": True, "include_chunk_content": True})
except Exception as exc: # noqa: BLE001
st.error(f"Chyba pri dopyte na LightRAG: {exc}")
st.stop()
st.session_state.result = {
"question": question, "query": query, "user_prompt": payload.get("user_prompt"),
"matches": matches, "data": data, "answer": answer,
}
result = st.session_state.get("result")
if result:
matches, data, answer = result["matches"], result["data"].get("data") or {}, result["answer"]
if matches:
st.subheader("Rozpoznané lieky")
for col, m in zip(st.columns(min(len(matches), 4)), matches):
with col.container(border=True):
st.markdown(f"**{m['label']}** \n{'účinná látka' if m['kind'] == 'substance' else 'obchodný názov'}")
if m["substances"]:
st.write("Látka: " + ", ".join(m["substances"]))
if m.get("ambiguous"):
st.warning("Značka má viac zložení. Uveďte presný prípravok.")
ex = m["example"]
st.write(f"{ex['rx'] or ''} \n{ex['form'] or ''}")
if m["in_graph"]:
st.success("Index obsahuje klinického reprezentanta")
else:
st.warning("Index neobsahuje klinického reprezentanta; dôvod treba overiť v ADC.")
st.caption(f"{m['count']} balení v ADC · [ADC]({ex['url']})")
answer_tab, graph_tab, source_tab, debug_tab = st.tabs(
["Odpoveď", "Znalostný graf", "Zdroje z ADC", "Detaily dopytu"]
)
with answer_tab:
st.markdown(answer.get("response", ""))
refs = answer.get("references") or []
if refs:
st.markdown("**Referencie**")
for ref in refs:
path = ref.get("file_path", "")
st.markdown(f"[{ref.get('reference_id')}] [{product_name(path, by_url)}]({path})")
with graph_tab:
entities = data.get("entities") or []
relations = data.get("relationships") or []
focus = {normalize(s) for m in matches for s in m["substances"] + [m["label"]]}
# Keep the most connected entities when the retrieved subgraph is large.
degree = Counter()
for r in relations:
degree[r["src_id"]] += 1
degree[r["tgt_id"]] += 1
# Context limits can omit endpoint entities while retaining their edges.
# Recover endpoint labels from the actual relations, without inventing descriptions.
present = {e["entity_name"] for e in entities}
entities = list(entities) + [
{"entity_name":name,"entity_type":"unknown","description":""}
for name in degree if name not in present
]
ranked = sorted(entities, key=lambda e: (normalize(e["entity_name"]) not in focus, -degree[e["entity_name"]]))
shown = ranked[:max_graph_nodes]
st.caption(
f"Samostatne vyhľadaný podgraf: {len(entities)} entít a {len(relations)} vzťahov "
f"(zobrazených {len(shown)} uzlov). Prejdite myšou na uzol alebo hranu pre popis."
)
graph_legend()
render_graph(
[{"id": e["entity_name"], "type": e.get("entity_type"), "description": e.get("description")} for e in shown],
[{"src": r["src_id"], "tgt": r["tgt_id"], "keywords": r.get("keywords", ""),
"description": r.get("description", "")} for r in relations],
focus,
)
with st.expander(f"Tabuľka vzťahov ({len(relations)})"):
st.dataframe(
[{
"typ": relation_kind(r.get("keywords", ""), r.get("description", ""))[0],
"od": r["src_id"], "k": r["tgt_id"],
"kľúčové slová": r.get("keywords", ""),
"popis": r.get("description", ""),
"zdroj": product_name(str(r.get("file_path", "")).split("<SEP>")[0], by_url),
} for r in relations],
width="stretch", hide_index=True,
)
with source_tab:
chunks = [
{"file_path":ref.get("file_path", ""),"content":content}
for ref in answer.get("references") or [] for content in ref.get("content") or []
]
st.caption(f"{len(chunks)} zdrojových úryvkov vrátených s touto odpoveďou. "
"Podgraf bol vyhľadaný samostatným volaním a nemusí presne zodpovedať kontextu generovania.")
if not chunks:
st.warning("Odpoveď neobsahuje zdrojové úryvky. Jej tvrdenia nemožno overiť z tohto výstupu.")
grouped = defaultdict(list)
for chunk in chunks:
grouped[chunk.get("file_path", "")].append(chunk)
for path, items in grouped.items():
product = by_url.get(path)
title = product["name"] if product else path
with st.expander(f"📄 {title} ({len(items)} úryvkov)"):
if product:
st.markdown(f"[Detail v ADC]({path}) · [Príbalový leták]({product['pil']})")
for chunk in items:
st.markdown(f"<div style='white-space:pre-wrap;font-size:0.9em;border-left:3px solid #ccc;"
f"padding-left:8px;margin-bottom:12px'>{html.escape(chunk.get('content', ''))}</div>",
unsafe_allow_html=True)
with debug_tab:
meta = result["data"].get("metadata") or {}
st.write("**Dopyt odoslaný do LightRAG:**")
st.code(result["query"], language=None)
if result.get("user_prompt"):
st.write("**Doplňujúce údaje z ADC pre model:**")
st.code(result["user_prompt"], language=None)
st.write("**Kľúčové slová extrahované LLM:**", meta.get("keywords"))
st.json(meta.get("processing_info") or {})
# ---------------------------------------------------------------- tab: explore
with tab_explore:
st.write("Vyhľadajte entitu (liečivo, ochorenie, skupinu ...) a zobrazte jej okolie v znalostnom grafe.")
term = st.text_input("Hľadať entitu", "warfarín")
labels = []
if term.strip():
try:
labels = api(base_url, "GET", "/graph/label/search", params={"q": term, "limit": 30}, timeout=30)
except Exception as exc: # noqa: BLE001
st.error(f"Chyba: {exc}")
if labels:
col1, col2 = st.columns([3, 1])
label = col1.selectbox("Entita", labels)
depth = col2.radio("Hĺbka", [1, 2], horizontal=True)
graph = api(base_url, "GET", "/graphs", params={"label": label, "max_depth": depth, "max_nodes": max_graph_nodes}, timeout=60)
nodes = [{
"id": n["id"],
"type": (n.get("properties") or {}).get("entity_type"),
"description": (n.get("properties") or {}).get("description"),
} for n in graph.get("nodes", [])]
edges = [{
"src": e["source"], "tgt": e["target"],
"keywords": (e.get("properties") or {}).get("keywords", ""),
"description": (e.get("properties") or {}).get("description", ""),
} for e in graph.get("edges", [])]
st.caption(f"{len(nodes)} uzlov, {len(edges)} hrán")
graph_legend()
render_graph(nodes, edges, {normalize(label)})
center = next((n for n in nodes if n["id"] == label), None)
if center:
st.markdown(f"**{label}** ({center['type']})")
st.markdown(clean(center["description"], limit=3000))
elif term.strip():
st.info("Nenašla sa žiadna entita.")