bsenst commited on
Commit
5887b57
·
0 Parent(s):

Initial production-ready EBM Mentor

Browse files
.github/workflows/ci.yml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.11"
15
+ - name: Install dependencies
16
+ run: |
17
+ python -m pip install --upgrade pip
18
+ pip install -r requirements.txt
19
+ - name: Run tests
20
+ run: pytest
21
+
.gitignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ venv/
7
+ env/
8
+ dist/
9
+ build/
10
+ *.pyc
11
+ *.pyo
12
+ *.pyd
13
+ *.egg-info/
14
+ data/vector_store/
15
+ data/*.parquet
16
+ data/*.jsonl
17
+ data/*.npy
18
+ data/*.faiss
19
+ *.log
20
+
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1
6
+
7
+ WORKDIR /app
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ build-essential \
11
+ git \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ COPY requirements.txt /app/requirements.txt
15
+ RUN pip install --upgrade pip && pip install -r requirements.txt
16
+
17
+ COPY . /app
18
+
19
+ EXPOSE 7860
20
+
21
+ CMD ["python", "app.py"]
22
+
README.md ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: EBM Mentor
3
+ emoji: 🩺
4
+ colorFrom: teal
5
+ colorTo: blue
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # EBM Mentor
12
+
13
+ EBM Mentor is a Retrieval-Augmented Generation assistant for German EBM billing research. It helps physicians, coders, and practice staff search and understand EBM code descriptions, points, exclusions, notes, and eligibility details.
14
+
15
+ The system is RAG-only:
16
+
17
+ - `CohereLabs/tiny-aya-water` is used only for answer generation.
18
+ - All EBM knowledge comes from retrieval over locally indexed EBM XML data.
19
+ - If the answer is not in the retrieved context, the app must say:
20
+ `Diese Information ist nicht in den bereitgestellten EBM-Daten enthalten.`
21
+
22
+ ## Architecture
23
+
24
+ ```mermaid
25
+ flowchart LR
26
+ A[Official EBM XML] --> B[src/parser.py]
27
+ B --> C[Structured records]
28
+ C --> D[src/chunking.py]
29
+ D --> E[Searchable documents]
30
+ E --> F[src/embeddings.py]
31
+ F --> G[FAISS index]
32
+ G --> H[src/retriever.py]
33
+ H --> I[src/prompts.py]
34
+ I --> J[src/model.py]
35
+ J --> K[Gradio app]
36
+ ```
37
+
38
+ ## Features
39
+
40
+ - Free-form EBM question answering
41
+ - Code explanation with structured metadata
42
+ - Random quiz mode
43
+ - Search and chapter browsing
44
+ - Source citations and retrieved document viewer
45
+ - Confidence score for retrieval results
46
+
47
+ ## Repository Layout
48
+
49
+ ```text
50
+ ebm-rag-trainer/
51
+ ├── app.py
52
+ ├── requirements.txt
53
+ ├── README.md
54
+ ├── Dockerfile
55
+ ├── data/
56
+ │ └── ebm.xml
57
+ ├── src/
58
+ │ ├── parser.py
59
+ │ ├── chunking.py
60
+ │ ├── embeddings.py
61
+ │ ├── vector_store.py
62
+ │ ├── retriever.py
63
+ │ ├── prompts.py
64
+ │ ├── rag_pipeline.py
65
+ │ └── model.py
66
+ ├── scripts/
67
+ │ ├── build_database.py
68
+ │ └── ingest_ebm.py
69
+ ├── tests/
70
+ └── .github/
71
+ ```
72
+
73
+ ## Setup
74
+
75
+ ### 1. Create an environment
76
+
77
+ ```bash
78
+ python -m venv .venv
79
+ .venv\Scripts\activate
80
+ ```
81
+
82
+ ### 2. Install dependencies
83
+
84
+ ```bash
85
+ pip install -r requirements.txt
86
+ ```
87
+
88
+ ### 3. Add the official EBM XML
89
+
90
+ Place the official XML file at:
91
+
92
+ ```text
93
+ data/ebm.xml
94
+ ```
95
+
96
+ The repository includes a tiny demo XML so the codebase is runnable out of the box, but production use should replace it with the official current EBM source.
97
+
98
+ ### 4. Build the local database
99
+
100
+ ```bash
101
+ python scripts/ingest_ebm.py --xml data/ebm.xml --output data/processed
102
+ python scripts/build_database.py --xml data/ebm.xml --store data/vector_store
103
+ ```
104
+
105
+ ### 5. Run the app
106
+
107
+ ```bash
108
+ python app.py
109
+ ```
110
+
111
+ ## Hugging Face Spaces Deployment
112
+
113
+ 1. Create a new Hugging Face Space.
114
+ 2. Choose `Gradio` as the SDK.
115
+ 3. Upload this repository.
116
+ 4. Ensure `data/ebm.xml` is included or mounted in the Space.
117
+ 5. Let the Space build the local FAISS index on first launch or prebuild it with the scripts above.
118
+
119
+ ### Space Notes
120
+
121
+ - SDK: `gradio`
122
+ - Hardware: `CPU Basic / Zero`
123
+ - No external database
124
+ - No paid APIs
125
+ - Local FAISS index and local metadata files only
126
+
127
+ ## Example Screenshots
128
+
129
+ Add screenshots here after deployment:
130
+
131
+ ```text
132
+ docs/screenshots/ask-ebm.png
133
+ docs/screenshots/explain-code.png
134
+ docs/screenshots/quiz-mode.png
135
+ docs/screenshots/explore-ebm.png
136
+ ```
137
+
138
+ ## Sample Questions
139
+
140
+ - Was bedeutet EBM-Code 01100?
141
+ - Welche Punkte hat die Leistung?
142
+ - Gibt es Ausschlüsse für diesen Code?
143
+ - Welche Fachgruppen sind berechtigt?
144
+ - Welche Hinweise stehen in den Anmerkungen?
145
+
146
+ ## Testing
147
+
148
+ Run the local test suite:
149
+
150
+ ```bash
151
+ pytest
152
+ ```
153
+
154
+ The tests cover:
155
+
156
+ - XML parsing
157
+ - document chunking
158
+ - retrieval
159
+
160
+ ## Safety and Scope
161
+
162
+ This project is designed as a research assistant, not a legal authority. Always verify billing decisions against the official EBM source material and current practice guidance.
app.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import gradio as gr
6
+ import pandas as pd
7
+
8
+ from src.parser import parse_ebm_xml_to_dataframe
9
+ from src.rag_pipeline import EbmRAGPipeline, build_pipeline_from_paths
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parent
13
+ DATA_XML = ROOT / "data" / "ebm.xml"
14
+ STORE_DIR = ROOT / "data" / "vector_store"
15
+
16
+
17
+ PIPELINE: EbmRAGPipeline | None = None
18
+
19
+
20
+ def get_pipeline() -> EbmRAGPipeline:
21
+ global PIPELINE
22
+ if PIPELINE is None:
23
+ PIPELINE = build_pipeline_from_paths(DATA_XML, STORE_DIR)
24
+ return PIPELINE
25
+
26
+
27
+ def format_retrieved(results: list[dict]) -> str:
28
+ if not results:
29
+ return "No retrieved documents."
30
+ lines = []
31
+ for item in results:
32
+ lines.append(
33
+ f"### {item['code']} - {item.get('title') or 'Unbenannt'}\n"
34
+ f"Score: {item['score']:.3f}\n\n"
35
+ f"{item['text']}"
36
+ )
37
+ return "\n\n".join(lines)
38
+
39
+
40
+ def ask_ebm(question: str) -> tuple[str, str, float]:
41
+ pipeline = get_pipeline()
42
+ result = pipeline.answer(question)
43
+ return result["answer"], format_retrieved(result["retrieved_documents"]), result["confidence"]
44
+
45
+
46
+ def explain_code(code: str) -> tuple[str, str]:
47
+ pipeline = get_pipeline()
48
+ result = pipeline.explain_code(code)
49
+ return result["answer"], format_retrieved(result["retrieved_documents"])
50
+
51
+
52
+ def quiz_me() -> tuple[str, str, str]:
53
+ pipeline = get_pipeline()
54
+ doc = pipeline.random_document()
55
+ prompt = f"What does this code describe?\n\nEBM code: {doc.code}"
56
+ return prompt, gr.update(value="", visible=False), doc.code
57
+
58
+
59
+ def reveal_quiz_answer(code: str) -> tuple[str, str]:
60
+ pipeline = get_pipeline()
61
+ if not code:
62
+ return gr.update(value="No code selected.", visible=True), ""
63
+ result = pipeline.explain_code(code)
64
+ return gr.update(value=result["answer"], visible=True), format_retrieved(result["retrieved_documents"])
65
+
66
+
67
+ def explore_ebm(query: str, chapter: str) -> tuple[str, str, str]:
68
+ pipeline = get_pipeline()
69
+ results = pipeline.search(query=query, chapter=chapter, top_k=10)
70
+ if not results:
71
+ empty = pd.DataFrame(columns=["code", "title", "points", "exclusions", "notes"])
72
+ return empty, "No matches found.", ""
73
+
74
+ rows = []
75
+ for item in results:
76
+ rows.append(
77
+ {
78
+ "code": item["code"],
79
+ "title": item.get("title") or "",
80
+ "points": item.get("points") or "",
81
+ "exclusions": ", ".join(item.get("exclusions_text", [])),
82
+ "notes": " | ".join(item.get("notes", [])),
83
+ }
84
+ )
85
+
86
+ table = pd.DataFrame(rows)
87
+ details = format_retrieved(results)
88
+ chapters = "\n".join(sorted({item.get("chapter_name") or "" for item in results if item.get("chapter_name")}))
89
+ return table, details, chapters
90
+
91
+
92
+ def browse_chapters() -> list[str]:
93
+ if STORE_DIR.exists() and (STORE_DIR / "metadata.jsonl").exists():
94
+ pipeline = get_pipeline()
95
+ chapters = pipeline.list_chapters()
96
+ elif DATA_XML.exists():
97
+ df = parse_ebm_xml_to_dataframe(str(DATA_XML))
98
+ chapters = sorted(
99
+ {
100
+ str(value)
101
+ for value in df.get("chapter_name", pd.Series(dtype=str)).dropna().tolist()
102
+ if str(value).strip()
103
+ }
104
+ )
105
+ else:
106
+ chapters = []
107
+ return ["All"] + chapters
108
+
109
+
110
+ CSS = """
111
+ :root {
112
+ --bg: #f4f7f6;
113
+ --panel: rgba(255,255,255,0.85);
114
+ --ink: #183a37;
115
+ --muted: #4e6964;
116
+ --accent: #2e7d6b;
117
+ --accent-2: #165d50;
118
+ --border: rgba(24,58,55,0.12);
119
+ }
120
+
121
+ body, .gradio-container {
122
+ background:
123
+ radial-gradient(circle at top left, rgba(46,125,107,0.12), transparent 32%),
124
+ radial-gradient(circle at top right, rgba(24,58,55,0.08), transparent 28%),
125
+ linear-gradient(180deg, #f7fbfa 0%, #eef4f2 100%);
126
+ color: var(--ink);
127
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
128
+ }
129
+
130
+ .hero {
131
+ background: linear-gradient(135deg, rgba(24,58,55,0.95), rgba(46,125,107,0.92));
132
+ color: white;
133
+ border-radius: 24px;
134
+ padding: 28px;
135
+ box-shadow: 0 20px 60px rgba(24,58,55,0.18);
136
+ }
137
+
138
+ .hero h1 {
139
+ margin: 0;
140
+ font-size: 2.4rem;
141
+ letter-spacing: -0.03em;
142
+ }
143
+
144
+ .hero p {
145
+ margin: 8px 0 0 0;
146
+ opacity: 0.92;
147
+ font-size: 1.05rem;
148
+ }
149
+
150
+ .card {
151
+ background: var(--panel);
152
+ border: 1px solid var(--border);
153
+ border-radius: 20px;
154
+ backdrop-filter: blur(10px);
155
+ box-shadow: 0 10px 30px rgba(24,58,55,0.08);
156
+ }
157
+
158
+ .gr-button-primary {
159
+ background: linear-gradient(135deg, var(--accent), var(--accent-2)) !important;
160
+ border: none !important;
161
+ }
162
+ """
163
+
164
+
165
+ def build_app() -> gr.Blocks:
166
+ with gr.Blocks(css=CSS, theme=gr.themes.Base()) as demo:
167
+ gr.HTML(
168
+ """
169
+ <div class="hero">
170
+ <h1>EBM Mentor</h1>
171
+ <p>Learn and explore the German EBM interactively.</p>
172
+ </div>
173
+ """
174
+ )
175
+
176
+ chapter_choices = browse_chapters()
177
+
178
+ with gr.Tabs():
179
+ with gr.Tab("Ask EBM"):
180
+ with gr.Row():
181
+ question = gr.Textbox(
182
+ label="Ask a question",
183
+ placeholder="Was bedeutet EBM-Code 01100?",
184
+ lines=3,
185
+ )
186
+ ask_btn = gr.Button("Ask EBM", variant="primary")
187
+ answer = gr.Markdown()
188
+ confidence = gr.Slider(0, 1, value=0, label="Retrieval confidence", interactive=False)
189
+ ask_sources = gr.Markdown()
190
+ ask_btn.click(ask_ebm, inputs=[question], outputs=[answer, ask_sources, confidence])
191
+
192
+ with gr.Tab("Explain a Code"):
193
+ code_input = gr.Textbox(label="EBM code", placeholder="01100")
194
+ explain_btn = gr.Button("Explain", variant="primary")
195
+ explanation = gr.Markdown()
196
+ explanation_sources = gr.Markdown()
197
+ explain_btn.click(explain_code, inputs=[code_input], outputs=[explanation, explanation_sources])
198
+
199
+ with gr.Tab("Quiz Me"):
200
+ quiz_prompt = gr.Markdown()
201
+ quiz_answer = gr.Markdown(visible=False)
202
+ quiz_sources = gr.Markdown()
203
+ quiz_code = gr.State("")
204
+ quiz_btn = gr.Button("Random code", variant="primary")
205
+ reveal_btn = gr.Button("Reveal answer")
206
+ quiz_btn.click(
207
+ quiz_me,
208
+ inputs=None,
209
+ outputs=[quiz_prompt, quiz_answer, quiz_code],
210
+ )
211
+ reveal_btn.click(
212
+ reveal_quiz_answer,
213
+ inputs=[quiz_code],
214
+ outputs=[quiz_answer, quiz_sources],
215
+ )
216
+
217
+ with gr.Tab("Explore EBM"):
218
+ with gr.Row():
219
+ search_query = gr.Textbox(label="Search", placeholder="points, exclusions, title, notes...")
220
+ chapter = gr.Dropdown(
221
+ label="Chapter",
222
+ choices=chapter_choices,
223
+ value="All",
224
+ interactive=True,
225
+ )
226
+ search_btn = gr.Button("Search", variant="primary")
227
+ table = gr.Dataframe(
228
+ headers=["code", "title", "points", "exclusions", "notes"],
229
+ datatype=["str", "str", "str", "str", "str"],
230
+ interactive=False,
231
+ wrap=True,
232
+ row_count=(5, "dynamic"),
233
+ )
234
+ explore_sources = gr.Markdown()
235
+ explore_chapters = gr.Markdown()
236
+ search_btn.click(
237
+ explore_ebm,
238
+ inputs=[search_query, chapter],
239
+ outputs=[table, explore_sources, explore_chapters],
240
+ )
241
+
242
+ return demo
243
+
244
+
245
+ app = build_app()
246
+
247
+
248
+ if __name__ == "__main__":
249
+ app.launch()
data/ebm.xml ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ehd:root xmlns:ehd="urn:ehd/001" xmlns:go="urn:ehd/go/001">
3
+ <ehd:body>
4
+ <go:gnr_liste>
5
+ <go:gnr V="01100" USE="1" VT="20240101">
6
+ <go:allgemein>
7
+ <go:legende>
8
+ <go:kurztext V="Unvorhergesehene Inanspruchnahme I"/>
9
+ <go:quittungstext V="Unvorhergesehene Inanspruchnahme I"/>
10
+ <go:langtext>Notdienstliche Erstversorgung und kurzfristige Inanspruchnahme außerhalb der regulären Sprechzeiten.</go:langtext>
11
+ <go:kap_bez V="01" DN="Allgemeine Leistungen">
12
+ <go:bereich DN="Allgemeinmedizin"/>
13
+ <go:kapitel DN="Hausärztliche Versorgung"/>
14
+ <go:abschnitt DN="Unvorhergesehene Inanspruchnahme"/>
15
+ </go:kap_bez>
16
+ </go:legende>
17
+ <go:anmerkungen_liste>
18
+ <go:anmerkung>Nur einmal je Behandlungstag berechnungsfähig.</go:anmerkung>
19
+ </go:anmerkungen_liste>
20
+ <go:bewertung_liste>
21
+ <go:bewertung V="196" U="PUNKTE">
22
+ <go:leistung_typ V="GKV"/>
23
+ </go:bewertung>
24
+ </go:bewertung_liste>
25
+ </go:allgemein>
26
+ <go:regel>
27
+ <go:ausschluss_liste>
28
+ <go:bezugsraum>
29
+ <go:gnr_liste>
30
+ <go:gnr V="01101" DN="Unvorhergesehene Inanspruchnahme II"/>
31
+ <go:gnr V="01200" DN="Weitere Inanspruchnahme"/>
32
+ </go:gnr_liste>
33
+ </go:bezugsraum>
34
+ </go:ausschluss_liste>
35
+ </go:regel>
36
+ <go:vdx>
37
+ <go:gkv_kontenart_liste>
38
+ <go:gkv_kontenart V="A"/>
39
+ <go:gkv_kontenart V="B"/>
40
+ </go:gkv_kontenart_liste>
41
+ </go:vdx>
42
+ </go:gnr>
43
+ <go:gnr V="01732" USE="1" VT="20240101">
44
+ <go:allgemein>
45
+ <go:legende>
46
+ <go:kurztext V="Gesundheitsuntersuchung"/>
47
+ <go:quittungstext V="Gesundheitsuntersuchung"/>
48
+ <go:langtext>Vorsorgeorientierte Untersuchung bei asymptomatischen Patienten.</go:langtext>
49
+ <go:kap_bez V="02" DN="Vorsorgeleistungen">
50
+ <go:bereich DN="Prävention"/>
51
+ <go:kapitel DN="Vorsorge"/>
52
+ <go:abschnitt DN="Gesundheitsuntersuchungen"/>
53
+ </go:kap_bez>
54
+ </go:legende>
55
+ <go:anmerkungen_liste>
56
+ <go:anmerkung>Einmal innerhalb definierter Fristen abrechenbar.</go:anmerkung>
57
+ </go:anmerkungen_liste>
58
+ <go:bewertung_liste>
59
+ <go:bewertung V="300" U="PUNKTE">
60
+ <go:leistung_typ V="GKV"/>
61
+ </go:bewertung>
62
+ </go:bewertung_liste>
63
+ </go:allgemein>
64
+ <go:regel>
65
+ <go:ausschluss_liste>
66
+ <go:bezugsraum>
67
+ <go:gnr_liste>
68
+ <go:gnr V="01733" DN="Weiteres Vorsorgepaket"/>
69
+ </go:gnr_liste>
70
+ </go:bezugsraum>
71
+ </go:ausschluss_liste>
72
+ </go:regel>
73
+ <go:vdx>
74
+ <go:gkv_kontenart_liste>
75
+ <go:gkv_kontenart V="A"/>
76
+ </go:gkv_kontenart_liste>
77
+ </go:vdx>
78
+ </go:gnr>
79
+ </go:gnr_liste>
80
+ </ehd:body>
81
+ </ehd:root>
82
+
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ pandas>=2.2.2
3
+ numpy>=1.26.4
4
+ sentence-transformers>=3.0.1
5
+ transformers>=4.44.2
6
+ accelerate>=0.33.0
7
+ torch>=2.3.1
8
+ faiss-cpu>=1.8.0.post1
9
+ pytest>=8.3.2
10
+ pyarrow>=17.0.0
11
+
scripts/build_database.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from src.chunking import dataframe_to_documents
7
+ from src.embeddings import EmbeddingModel
8
+ from src.parser import parse_ebm_xml_to_dataframe
9
+ from src.vector_store import EbmVectorStore
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(description="Build the local FAISS database from EBM XML.")
14
+ parser.add_argument("--xml", required=True, help="Path to the official EBM XML.")
15
+ parser.add_argument("--store", required=True, help="Output directory for the FAISS store.")
16
+ parser.add_argument("--model", default=None, help="Optional sentence-transformers model name.")
17
+ args = parser.parse_args()
18
+
19
+ xml_path = Path(args.xml)
20
+ store_dir = Path(args.store)
21
+ embedding_model = EmbeddingModel(args.model) if args.model else EmbeddingModel()
22
+
23
+ df = parse_ebm_xml_to_dataframe(str(xml_path))
24
+ documents = dataframe_to_documents(df)
25
+ store, embeddings = EbmVectorStore.build(documents, embedding_model=embedding_model)
26
+ store.save(store_dir, embeddings=embeddings)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
31
+
scripts/ingest_ebm.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from src.chunking import dataframe_to_search_corpus
8
+ from src.parser import parse_ebm_xml_to_dataframe
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description="Parse the EBM XML into structured artifacts.")
13
+ parser.add_argument("--xml", required=True, help="Path to the official EBM XML.")
14
+ parser.add_argument("--output", required=True, help="Directory for generated artifacts.")
15
+ args = parser.parse_args()
16
+
17
+ xml_path = Path(args.xml)
18
+ output_dir = Path(args.output)
19
+ output_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ df = parse_ebm_xml_to_dataframe(str(xml_path))
22
+ df.to_parquet(output_dir / "ebm.parquet", index=False)
23
+ df.to_json(output_dir / "ebm.jsonl", orient="records", lines=True, force_ascii=False)
24
+
25
+ corpus = dataframe_to_search_corpus(df)
26
+ (output_dir / "ebm_documents.jsonl").write_text(
27
+ "\n".join(json.dumps(item, ensure_ascii=False) for item in corpus),
28
+ encoding="utf-8",
29
+ )
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
src/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """EBM RAG package."""
2
+
src/chunking.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class EbmDocument:
11
+ code: str
12
+ title: str
13
+ short_text: str | None
14
+ receipt_text: str | None
15
+ long_text: str | None
16
+ chapter_code: str | None
17
+ chapter_name: str | None
18
+ bereich: str | None
19
+ kapitel: str | None
20
+ abschnitt: str | None
21
+ notes: list[str]
22
+ points: int | None
23
+ fachgruppen: list[str]
24
+ exclusions: list[dict[str, str | None]]
25
+ gkv_account_types: list[str]
26
+ raw: dict[str, Any] | None = None
27
+
28
+
29
+ def _coerce_points(value: Any) -> int | None:
30
+ if value in (None, ""):
31
+ return None
32
+ try:
33
+ return int(float(value))
34
+ except (TypeError, ValueError):
35
+ return None
36
+
37
+
38
+ def _safe_list(value: Any) -> list[Any]:
39
+ if isinstance(value, list):
40
+ return value
41
+ return []
42
+
43
+
44
+ def dataframe_to_documents(df: pd.DataFrame) -> list[EbmDocument]:
45
+ documents: list[EbmDocument] = []
46
+ for _, row in df.iterrows():
47
+ data = row.to_dict()
48
+ title = data.get("short_text") or data.get("receipt_text") or data.get("code") or ""
49
+ documents.append(
50
+ EbmDocument(
51
+ code=str(data.get("code") or ""),
52
+ title=str(title),
53
+ short_text=data.get("short_text"),
54
+ receipt_text=data.get("receipt_text"),
55
+ long_text=data.get("long_text"),
56
+ chapter_code=data.get("chapter_code"),
57
+ chapter_name=data.get("chapter_name"),
58
+ bereich=data.get("bereich"),
59
+ kapitel=data.get("kapitel"),
60
+ abschnitt=data.get("abschnitt"),
61
+ notes=[str(item) for item in _safe_list(data.get("notes")) if item],
62
+ points=_coerce_points(data.get("points")),
63
+ fachgruppen=[str(item) for item in _safe_list(data.get("fachgruppen")) if item],
64
+ exclusions=[
65
+ {
66
+ "code": item.get("code"),
67
+ "description": item.get("description"),
68
+ }
69
+ for item in _safe_list(data.get("exclusions"))
70
+ if isinstance(item, dict)
71
+ ],
72
+ gkv_account_types=[str(item) for item in _safe_list(data.get("gkv_account_types")) if item],
73
+ raw=data,
74
+ )
75
+ )
76
+ return documents
77
+
78
+
79
+ def _format_bullets(items: list[str]) -> str:
80
+ return "\n".join(f"- {item}" for item in items) if items else "Nicht angegeben."
81
+
82
+
83
+ def _format_exclusions(items: list[dict[str, str | None]]) -> str:
84
+ if not items:
85
+ return "Keine Ausschlüsse angegeben."
86
+ formatted = []
87
+ for item in items:
88
+ code = item.get("code") or ""
89
+ description = item.get("description") or ""
90
+ if description:
91
+ formatted.append(f"- {code}: {description}")
92
+ else:
93
+ formatted.append(f"- {code}")
94
+ return "\n".join(formatted)
95
+
96
+
97
+ def document_to_search_text(doc: EbmDocument) -> str:
98
+ parts = [
99
+ f"EBM Code: {doc.code}",
100
+ f"Title: {doc.title}",
101
+ ]
102
+ if doc.short_text:
103
+ parts.append(f"Short text: {doc.short_text}")
104
+ if doc.receipt_text:
105
+ parts.append(f"Receipt text: {doc.receipt_text}")
106
+ if doc.long_text:
107
+ parts.append(f"Description: {doc.long_text}")
108
+ if doc.points is not None:
109
+ parts.append(f"Points: {doc.points}")
110
+ if doc.notes:
111
+ parts.append("Notes:\n" + _format_bullets(doc.notes))
112
+ if doc.exclusions:
113
+ parts.append("Exclusions:\n" + _format_exclusions(doc.exclusions))
114
+ if doc.fachgruppen:
115
+ parts.append("Fachgruppen:\n" + _format_bullets(doc.fachgruppen))
116
+ if doc.gkv_account_types:
117
+ parts.append("GKV account types:\n" + _format_bullets(doc.gkv_account_types))
118
+ if doc.chapter_name:
119
+ parts.append(f"Chapter: {doc.chapter_name}")
120
+ if doc.kapitel:
121
+ parts.append(f"Kapitel: {doc.kapitel}")
122
+ if doc.abschnitt:
123
+ parts.append(f"Abschnitt: {doc.abschnitt}")
124
+ return "\n\n".join(parts)
125
+
126
+
127
+ def document_to_structured_dict(doc: EbmDocument) -> dict[str, Any]:
128
+ payload = asdict(doc)
129
+ payload["search_text"] = document_to_search_text(doc)
130
+ return payload
131
+
132
+
133
+ def dataframe_to_search_corpus(df: pd.DataFrame) -> list[dict[str, Any]]:
134
+ docs = dataframe_to_documents(df)
135
+ return [document_to_structured_dict(doc) for doc in docs]
136
+
src/embeddings.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterable
5
+
6
+ import numpy as np
7
+ from sentence_transformers import SentenceTransformer
8
+
9
+
10
+ DEFAULT_EMBEDDING_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
11
+
12
+
13
+ @dataclass
14
+ class EmbeddingConfig:
15
+ model_name: str = DEFAULT_EMBEDDING_MODEL
16
+
17
+
18
+ class EmbeddingModel:
19
+ def __init__(self, model_name: str = DEFAULT_EMBEDDING_MODEL):
20
+ self.model_name = model_name
21
+ self._model: SentenceTransformer | None = None
22
+
23
+ @property
24
+ def model(self) -> SentenceTransformer:
25
+ if self._model is None:
26
+ self._model = SentenceTransformer(self.model_name)
27
+ return self._model
28
+
29
+ def encode(self, texts: Iterable[str]) -> np.ndarray:
30
+ embeddings = self.model.encode(
31
+ list(texts),
32
+ normalize_embeddings=True,
33
+ convert_to_numpy=True,
34
+ show_progress_bar=False,
35
+ )
36
+ return np.asarray(embeddings, dtype=np.float32)
37
+
src/model.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
5
+
6
+
7
+ DEFAULT_GENERATION_MODEL = "CohereLabs/tiny-aya-water"
8
+
9
+
10
+ def load_generation_pipeline(model_name: str = DEFAULT_GENERATION_MODEL):
11
+ tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
12
+ dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float32
13
+ model = AutoModelForCausalLM.from_pretrained(
14
+ model_name,
15
+ trust_remote_code=True,
16
+ torch_dtype=dtype,
17
+ device_map="auto",
18
+ low_cpu_mem_usage=True,
19
+ )
20
+ return pipeline(
21
+ "text-generation",
22
+ model=model,
23
+ tokenizer=tokenizer,
24
+ max_new_tokens=256,
25
+ do_sample=False,
26
+ temperature=0.0,
27
+ return_full_text=False,
28
+ pad_token_id=tokenizer.eos_token_id,
29
+ )
30
+
src/parser.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import xml.etree.ElementTree as ET
5
+
6
+ import pandas as pd
7
+
8
+
9
+ NAMESPACES = {"ehd": "urn:ehd/001", "go": "urn:ehd/go/001"}
10
+
11
+
12
+ def get_text_content(elem: ET.Element | None) -> str | None:
13
+ """Extract all text recursively from an XML element."""
14
+ if elem is None:
15
+ return None
16
+
17
+ text = " ".join(t.strip() for t in elem.itertext() if t.strip())
18
+ return text if text else None
19
+
20
+
21
+ def parse_ebm_xml_to_dataframe(xml_path: str) -> pd.DataFrame:
22
+ """Parse EBM XML into a pandas DataFrame with one row per GNR."""
23
+ tree = ET.parse(xml_path)
24
+ root = tree.getroot()
25
+
26
+ rows: list[dict[str, Any]] = []
27
+
28
+ for gnr in root.findall("./ehd:body/go:gnr_liste/go:gnr", namespaces=NAMESPACES):
29
+ row: dict[str, Any] = {
30
+ "code": gnr.get("V"),
31
+ "use": gnr.get("USE"),
32
+ "valid_from": gnr.get("VT"),
33
+ }
34
+
35
+ legende = gnr.find("./go:allgemein/go:legende", namespaces=NAMESPACES)
36
+ if legende is not None:
37
+ kurztext = legende.find("go:kurztext", namespaces=NAMESPACES)
38
+ quittungstext = legende.find("go:quittungstext", namespaces=NAMESPACES)
39
+ langtext = legende.find("go:langtext", namespaces=NAMESPACES)
40
+ kap_bez = legende.find("go:kap_bez", namespaces=NAMESPACES)
41
+
42
+ row["short_text"] = kurztext.get("V") if kurztext is not None else None
43
+ row["receipt_text"] = quittungstext.get("V") if quittungstext is not None else None
44
+ row["long_text"] = get_text_content(langtext)
45
+
46
+ if kap_bez is not None:
47
+ bereich = kap_bez.find("go:bereich", namespaces=NAMESPACES)
48
+ kapitel = kap_bez.find("go:kapitel", namespaces=NAMESPACES)
49
+ abschnitt = kap_bez.find("go:abschnitt", namespaces=NAMESPACES)
50
+ row["chapter_code"] = kap_bez.get("V")
51
+ row["chapter_name"] = kap_bez.get("DN")
52
+ row["bereich"] = bereich.get("DN") if bereich is not None else None
53
+ row["kapitel"] = kapitel.get("DN") if kapitel is not None else None
54
+ row["abschnitt"] = abschnitt.get("DN") if abschnitt is not None else None
55
+
56
+ row["service_period"] = (
57
+ gnr.find("./go:allgemein/go:gueltigkeit/go:service_tmr", namespaces=NAMESPACES).get("V")
58
+ if gnr.find("./go:allgemein/go:gueltigkeit/go:service_tmr", namespaces=NAMESPACES) is not None
59
+ else None
60
+ )
61
+ row["effective_period"] = (
62
+ gnr.find("./go:allgemein/go:gueltigkeit/go:effective_tmr", namespaces=NAMESPACES).get("V")
63
+ if gnr.find("./go:allgemein/go:gueltigkeit/go:effective_tmr", namespaces=NAMESPACES) is not None
64
+ else None
65
+ )
66
+
67
+ notes: list[str] = []
68
+ for note in gnr.findall("./go:allgemein/go:anmerkungen_liste/go:anmerkung", namespaces=NAMESPACES):
69
+ txt = get_text_content(note)
70
+ if txt:
71
+ notes.append(txt)
72
+ row["notes"] = notes
73
+
74
+ bewertung = gnr.find("./go:allgemein/go:bewertung_liste/go:bewertung", namespaces=NAMESPACES)
75
+ if bewertung is not None:
76
+ row["points"] = bewertung.get("V")
77
+ row["unit"] = bewertung.get("U")
78
+ lt = bewertung.find("go:leistung_typ", namespaces=NAMESPACES)
79
+ row["leistung_typ"] = lt.get("V") if lt is not None else None
80
+
81
+ fachgruppen: list[str] = []
82
+ for fg in gnr.findall(".//go:fachgruppe_liste//go:fachgruppe", namespaces=NAMESPACES):
83
+ value = fg.get("V")
84
+ if value:
85
+ fachgruppen.append(value)
86
+ row["fachgruppen"] = fachgruppen
87
+
88
+ exclusions: list[dict[str, str | None]] = []
89
+ for ex in gnr.findall("./go:regel/go:ausschluss_liste/go:bezugsraum", namespaces=NAMESPACES):
90
+ for ex_gnr in ex.findall("./go:gnr_liste/go:gnr", namespaces=NAMESPACES):
91
+ exclusions.append(
92
+ {
93
+ "code": ex_gnr.get("V"),
94
+ "description": ex_gnr.get("DN"),
95
+ }
96
+ )
97
+ row["exclusions"] = exclusions
98
+
99
+ gkv_types: list[str] = []
100
+ for gkv in gnr.findall("./go:vdx/go:gkv_kontenart_liste/go:gkv_kontenart", namespaces=NAMESPACES):
101
+ value = gkv.get("V")
102
+ if value:
103
+ gkv_types.append(value)
104
+ row["gkv_account_types"] = gkv_types
105
+
106
+ rows.append(row)
107
+
108
+ return pd.DataFrame(rows)
109
+
src/prompts.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ NO_ANSWER_TEXT = "Diese Information ist nicht in den bereitgestellten EBM-Daten enthalten."
5
+
6
+ ANSWER_PROMPT = """You are an EBM billing tutor.
7
+ Answer ONLY using the provided context.
8
+ If the answer cannot be found in the context, say:
9
+
10
+ "Diese Information ist nicht in den bereitgestellten EBM-Daten enthalten."
11
+
12
+ Context:
13
+ {retrieved_documents}
14
+
15
+ Question:
16
+ {user_question}
17
+
18
+ Answer:
19
+ """
20
+
21
+ CODE_EXPLANATION_PROMPT = """You are an EBM billing tutor.
22
+ Explain this code ONLY using the provided context.
23
+ Return a concise, structured explanation with the fields:
24
+ - Code
25
+ - Title
26
+ - Description
27
+ - Points
28
+ - Notes
29
+ - Exclusions
30
+ - Fachgruppen
31
+ - GKV account types
32
+
33
+ If a field is missing, say it is not provided in the context.
34
+
35
+ Context:
36
+ {retrieved_documents}
37
+
38
+ Question:
39
+ {user_question}
40
+
41
+ Answer:
42
+ """
43
+
src/rag_pipeline.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from src.chunking import dataframe_to_documents
7
+ from src.embeddings import EmbeddingModel
8
+ from src.model import load_generation_pipeline
9
+ from src.prompts import ANSWER_PROMPT, CODE_EXPLANATION_PROMPT, NO_ANSWER_TEXT
10
+ from src.parser import parse_ebm_xml_to_dataframe
11
+ from src.retriever import EbmRetriever
12
+ from src.vector_store import EbmVectorStore
13
+
14
+
15
+ def _format_context(results: list[dict[str, Any]]) -> str:
16
+ blocks = []
17
+ for item in results:
18
+ notes = "\n".join(f"- {note}" for note in item.get("notes", [])) or "Keine."
19
+ exclusions = "\n".join(
20
+ f"- {ex['code']}: {ex.get('description') or ''}".strip()
21
+ for ex in item.get("exclusions", [])
22
+ if ex.get("code")
23
+ ) or "Keine."
24
+ blocks.append(
25
+ "\n".join(
26
+ [
27
+ f"EBM Code: {item.get('code')}",
28
+ f"Title: {item.get('title') or ''}",
29
+ f"Points: {item.get('points') if item.get('points') is not None else 'Nicht angegeben'}",
30
+ f"Chapter: {item.get('chapter_name') or ''}",
31
+ f"Description: {item.get('long_text') or item.get('short_text') or ''}",
32
+ f"Notes:\n{notes}",
33
+ f"Exclusions:\n{exclusions}",
34
+ f"Fachgruppen: {', '.join(item.get('fachgruppen', [])) or 'Nicht angegeben'}",
35
+ f"GKV account types: {', '.join(item.get('gkv_account_types', [])) or 'Nicht angegeben'}",
36
+ ]
37
+ )
38
+ )
39
+ return "\n\n---\n\n".join(blocks)
40
+
41
+
42
+ def _extract_citations(results: list[dict[str, Any]]) -> list[str]:
43
+ citations = []
44
+ for item in results:
45
+ code = item.get("code")
46
+ title = item.get("title")
47
+ if code:
48
+ citations.append(f"{code} - {title}".strip())
49
+ return citations
50
+
51
+
52
+ class EbmRAGPipeline:
53
+ def __init__(self, retriever: EbmRetriever, generator=None):
54
+ self.retriever = retriever
55
+ self._generator = generator
56
+
57
+ @property
58
+ def generator(self):
59
+ if self._generator is None:
60
+ self._generator = load_generation_pipeline()
61
+ return self._generator
62
+
63
+ def answer(self, question: str, top_k: int = 5, chapter: str | None = None) -> dict[str, Any]:
64
+ retrieved = self.retriever.retrieve(question, top_k=top_k, chapter=chapter)
65
+ if not retrieved:
66
+ return {
67
+ "answer": NO_ANSWER_TEXT,
68
+ "retrieved_documents": [],
69
+ "confidence": 0.0,
70
+ "citations": [],
71
+ }
72
+
73
+ context = _format_context(retrieved)
74
+ prompt = ANSWER_PROMPT.format(retrieved_documents=context, user_question=question)
75
+ try:
76
+ generated = self.generator(prompt)[0]["generated_text"].strip()
77
+ except Exception:
78
+ generated = ""
79
+ answer = generated or NO_ANSWER_TEXT
80
+ confidence = max(0.0, min(1.0, float(retrieved[0].get("confidence", 0.0))))
81
+ return {
82
+ "answer": answer,
83
+ "retrieved_documents": retrieved,
84
+ "confidence": confidence,
85
+ "citations": _extract_citations(retrieved),
86
+ }
87
+
88
+ def explain_code(self, code: str) -> dict[str, Any]:
89
+ code = code.strip()
90
+ document = self.retriever.get_by_code(code)
91
+ if not document:
92
+ return {
93
+ "answer": NO_ANSWER_TEXT,
94
+ "retrieved_documents": [],
95
+ "confidence": 0.0,
96
+ "citations": [],
97
+ }
98
+
99
+ retrieved = [dict(document)]
100
+ context = _format_context(retrieved)
101
+ prompt = CODE_EXPLANATION_PROMPT.format(retrieved_documents=context, user_question=f"Explain EBM code {code}.")
102
+ try:
103
+ generated = self.generator(prompt)[0]["generated_text"].strip()
104
+ except Exception:
105
+ generated = ""
106
+ confidence = 1.0
107
+ return {
108
+ "answer": generated or NO_ANSWER_TEXT,
109
+ "retrieved_documents": retrieved,
110
+ "confidence": confidence,
111
+ "citations": _extract_citations(retrieved),
112
+ }
113
+
114
+ def search(self, query: str, top_k: int = 10, chapter: str | None = None) -> list[dict[str, Any]]:
115
+ return self.retriever.search(query=query, top_k=top_k, chapter=chapter)
116
+
117
+ def random_document(self):
118
+ from types import SimpleNamespace
119
+
120
+ doc = self.retriever.random_document()
121
+ return SimpleNamespace(**doc)
122
+
123
+ def list_chapters(self) -> list[str]:
124
+ return self.retriever.list_chapters()
125
+
126
+
127
+ def build_pipeline_from_paths(xml_path: str | Path, store_dir: str | Path, embedding_model: EmbeddingModel | None = None) -> EbmRAGPipeline:
128
+ xml_path = Path(xml_path)
129
+ store_dir = Path(store_dir)
130
+ embedding_model = embedding_model or EmbeddingModel()
131
+
132
+ if store_dir.exists() and (store_dir / "index.faiss").exists() and (store_dir / "metadata.jsonl").exists():
133
+ store = EbmVectorStore.load(store_dir)
134
+ else:
135
+ df = parse_ebm_xml_to_dataframe(str(xml_path))
136
+ documents = dataframe_to_documents(df)
137
+ store, embeddings = EbmVectorStore.build(documents, embedding_model=embedding_model)
138
+ store.save(store_dir, embeddings=embeddings)
139
+
140
+ retriever = EbmRetriever(store, embedding_model=embedding_model)
141
+ return EbmRAGPipeline(retriever=retriever)
src/retriever.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from src.embeddings import EmbeddingModel
7
+ from src.vector_store import EbmVectorStore, RetrievalResult
8
+
9
+
10
+ CODE_PATTERN = re.compile(r"\b\d{5}\b")
11
+
12
+
13
+ class EbmRetriever:
14
+ def __init__(self, store: EbmVectorStore, embedding_model: EmbeddingModel | None = None):
15
+ self.store = store
16
+ self.embedding_model = embedding_model or EmbeddingModel(store.embedding_model_name)
17
+
18
+ def retrieve(self, query: str, top_k: int = 5, chapter: str | None = None) -> list[dict[str, Any]]:
19
+ if not query.strip():
20
+ return []
21
+
22
+ embeddings = self.embedding_model.encode([query])
23
+ results = self.store.search(embeddings, top_k=top_k * 3 if chapter and chapter != "All" else top_k)
24
+
25
+ payloads = [self._to_payload(result) for result in results]
26
+ if chapter and chapter != "All":
27
+ payloads = [item for item in payloads if item.get("chapter_name") == chapter]
28
+ return payloads[:top_k]
29
+
30
+ def get_by_code(self, code: str) -> dict[str, Any] | None:
31
+ code = code.strip()
32
+ for doc in self.store.documents:
33
+ if str(doc.get("code") or "") == code:
34
+ return dict(doc)
35
+ return None
36
+
37
+ def random_document(self) -> dict[str, Any]:
38
+ import random
39
+
40
+ if not self.store.documents:
41
+ raise ValueError("No documents available.")
42
+ return dict(random.choice(self.store.documents))
43
+
44
+ def list_chapters(self) -> list[str]:
45
+ chapters = sorted(
46
+ {
47
+ str(doc.get("chapter_name"))
48
+ for doc in self.store.documents
49
+ if doc.get("chapter_name")
50
+ }
51
+ )
52
+ return chapters
53
+
54
+ def search(self, query: str, top_k: int = 10, chapter: str | None = None) -> list[dict[str, Any]]:
55
+ return self.retrieve(query=query, top_k=top_k, chapter=chapter)
56
+
57
+ def code_from_text(self, text: str) -> str | None:
58
+ match = CODE_PATTERN.search(text or "")
59
+ return match.group(0) if match else None
60
+
61
+ @staticmethod
62
+ def _to_payload(result: RetrievalResult) -> dict[str, Any]:
63
+ payload = dict(result.structured)
64
+ payload["score"] = result.score
65
+ payload["title"] = result.title
66
+ payload["text"] = result.text
67
+ payload["confidence"] = max(0.0, min(1.0, (result.score + 1.0) / 2.0))
68
+ payload["exclusions_text"] = [
69
+ item.get("code")
70
+ for item in payload.get("exclusions", [])
71
+ if isinstance(item, dict) and item.get("code")
72
+ ]
73
+ return payload
src/vector_store.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Iterable
7
+
8
+ import faiss
9
+ import numpy as np
10
+
11
+ from src.chunking import EbmDocument, dataframe_to_documents, document_to_search_text, document_to_structured_dict
12
+ from src.embeddings import EmbeddingModel, DEFAULT_EMBEDDING_MODEL
13
+
14
+
15
+ @dataclass
16
+ class RetrievalResult:
17
+ code: str
18
+ title: str
19
+ score: float
20
+ text: str
21
+ structured: dict[str, Any]
22
+
23
+
24
+ class EbmVectorStore:
25
+ def __init__(self, index: faiss.Index | None, documents: list[dict[str, Any]], embedding_model_name: str):
26
+ self.index = index
27
+ self.documents = documents
28
+ self.embedding_model_name = embedding_model_name
29
+
30
+ @classmethod
31
+ def build(
32
+ cls,
33
+ documents: Iterable[EbmDocument],
34
+ embedding_model: EmbeddingModel | None = None,
35
+ ) -> tuple["EbmVectorStore", np.ndarray]:
36
+ embedding_model = embedding_model or EmbeddingModel()
37
+ docs = [
38
+ document_to_structured_dict(doc) if hasattr(doc, "__dataclass_fields__") else dict(doc)
39
+ for doc in documents
40
+ ]
41
+ texts = [
42
+ document_to_search_text(EbmDocument(**{k: v for k, v in doc.items() if k != "search_text"}))
43
+ for doc in docs
44
+ ]
45
+ embeddings = embedding_model.encode(texts)
46
+ index = faiss.IndexFlatIP(embeddings.shape[1])
47
+ index.add(embeddings)
48
+ store = cls(index=index, documents=docs, embedding_model_name=embedding_model.model_name)
49
+ return store, embeddings
50
+
51
+ @classmethod
52
+ def from_dataframe(cls, df, embedding_model: EmbeddingModel | None = None) -> tuple["EbmVectorStore", np.ndarray]:
53
+ return cls.build(dataframe_to_documents(df), embedding_model=embedding_model)
54
+
55
+ def save(self, directory: str | Path, embeddings: np.ndarray | None = None) -> None:
56
+ path = Path(directory)
57
+ path.mkdir(parents=True, exist_ok=True)
58
+
59
+ if self.index is None:
60
+ raise ValueError("Cannot save a store without an index.")
61
+
62
+ faiss.write_index(self.index, str(path / "index.faiss"))
63
+ (path / "metadata.jsonl").write_text(
64
+ "\n".join(json.dumps(doc, ensure_ascii=False) for doc in self.documents),
65
+ encoding="utf-8",
66
+ )
67
+ (path / "config.json").write_text(
68
+ json.dumps({"embedding_model_name": self.embedding_model_name}, ensure_ascii=False, indent=2),
69
+ encoding="utf-8",
70
+ )
71
+ if embeddings is not None:
72
+ np.save(path / "embeddings.npy", embeddings)
73
+
74
+ @classmethod
75
+ def load(cls, directory: str | Path) -> "EbmVectorStore":
76
+ path = Path(directory)
77
+ index = faiss.read_index(str(path / "index.faiss"))
78
+ metadata_path = path / "metadata.jsonl"
79
+ documents = [json.loads(line) for line in metadata_path.read_text(encoding="utf-8").splitlines() if line.strip()]
80
+ config_path = path / "config.json"
81
+ if config_path.exists():
82
+ config = json.loads(config_path.read_text(encoding="utf-8"))
83
+ embedding_model_name = config.get("embedding_model_name", DEFAULT_EMBEDDING_MODEL)
84
+ else:
85
+ embedding_model_name = DEFAULT_EMBEDDING_MODEL
86
+ return cls(index=index, documents=documents, embedding_model_name=embedding_model_name)
87
+
88
+ def search(self, query_embedding: np.ndarray, top_k: int = 5) -> list[RetrievalResult]:
89
+ if self.index is None:
90
+ return []
91
+
92
+ query = np.asarray(query_embedding, dtype=np.float32)
93
+ if query.ndim == 1:
94
+ query = query[None, :]
95
+ scores, indices = self.index.search(query, top_k)
96
+
97
+ results: list[RetrievalResult] = []
98
+ for score, idx in zip(scores[0], indices[0]):
99
+ if idx < 0 or idx >= len(self.documents):
100
+ continue
101
+ doc = self.documents[idx]
102
+ structured = dict(doc)
103
+ search_text = structured.get("search_text")
104
+ if not search_text:
105
+ search_text = document_to_search_text(
106
+ EbmDocument(**{k: v for k, v in structured.items() if k != "search_text"})
107
+ )
108
+ results.append(
109
+ RetrievalResult(
110
+ code=str(doc.get("code") or ""),
111
+ title=str(doc.get("title") or doc.get("short_text") or doc.get("code") or ""),
112
+ score=float(score),
113
+ text=str(search_text or ""),
114
+ structured=structured,
115
+ )
116
+ )
117
+ return results
tests/fixtures/ebm_sample.xml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ehd:root xmlns:ehd="urn:ehd/001" xmlns:go="urn:ehd/go/001">
3
+ <ehd:body>
4
+ <go:gnr_liste>
5
+ <go:gnr V="01100" USE="1" VT="20240101">
6
+ <go:allgemein>
7
+ <go:legende>
8
+ <go:kurztext V="Unvorhergesehene Inanspruchnahme I"/>
9
+ <go:quittungstext V="QText"/>
10
+ <go:langtext>Langtext der Leistung.</go:langtext>
11
+ <go:kap_bez V="01" DN="Kapitel 01">
12
+ <go:bereich DN="Bereich A"/>
13
+ <go:kapitel DN="Kapitelname"/>
14
+ <go:abschnitt DN="Abschnittname"/>
15
+ </go:kap_bez>
16
+ </go:legende>
17
+ <go:anmerkungen_liste>
18
+ <go:anmerkung>Anmerkung 1</go:anmerkung>
19
+ </go:anmerkungen_liste>
20
+ <go:bewertung_liste>
21
+ <go:bewertung V="196" U="PUNKTE">
22
+ <go:leistung_typ V="GKV"/>
23
+ </go:bewertung>
24
+ </go:bewertung_liste>
25
+ </go:allgemein>
26
+ <go:regel>
27
+ <go:ausschluss_liste>
28
+ <go:bezugsraum>
29
+ <go:gnr_liste>
30
+ <go:gnr V="01101" DN="Ausschluss eins"/>
31
+ </go:gnr_liste>
32
+ </go:bezugsraum>
33
+ </go:ausschluss_liste>
34
+ </go:regel>
35
+ <go:vdx>
36
+ <go:gkv_kontenart_liste>
37
+ <go:gkv_kontenart V="A"/>
38
+ </go:gkv_kontenart_liste>
39
+ </go:vdx>
40
+ </go:gnr>
41
+ </go:gnr_liste>
42
+ </ehd:body>
43
+ </ehd:root>
44
+
tests/test_chunking.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+ from src.chunking import dataframe_to_documents, document_to_search_text
6
+
7
+
8
+ def test_dataframe_to_documents_and_search_text() -> None:
9
+ df = pd.DataFrame(
10
+ [
11
+ {
12
+ "code": "01100",
13
+ "short_text": "Unvorhergesehene Inanspruchnahme I",
14
+ "receipt_text": "Receipt",
15
+ "long_text": "Beschreibung.",
16
+ "chapter_code": "01",
17
+ "chapter_name": "Kapitel 01",
18
+ "bereich": "Bereich A",
19
+ "kapitel": "Kapitelname",
20
+ "abschnitt": "Abschnittname",
21
+ "notes": ["Note 1", "Note 2"],
22
+ "points": "196",
23
+ "fachgruppen": ["A", "B"],
24
+ "exclusions": [{"code": "01101", "description": "Ausschluss"}],
25
+ "gkv_account_types": ["A"],
26
+ }
27
+ ]
28
+ )
29
+
30
+ docs = dataframe_to_documents(df)
31
+ assert len(docs) == 1
32
+ text = document_to_search_text(docs[0])
33
+ assert "EBM Code: 01100" in text
34
+ assert "Points: 196" in text
35
+ assert "Exclusions:" in text
36
+
tests/test_parser.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from src.parser import parse_ebm_xml_to_dataframe
6
+
7
+
8
+ def test_parse_ebm_xml_to_dataframe(tmp_path: Path) -> None:
9
+ xml = tmp_path / "sample.xml"
10
+ xml.write_text(
11
+ """<?xml version="1.0" encoding="UTF-8"?>
12
+ <ehd:root xmlns:ehd="urn:ehd/001" xmlns:go="urn:ehd/go/001">
13
+ <ehd:body>
14
+ <go:gnr_liste>
15
+ <go:gnr V="01100" USE="1" VT="20240101">
16
+ <go:allgemein>
17
+ <go:legende>
18
+ <go:kurztext V="Unvorhergesehene Inanspruchnahme I"/>
19
+ <go:quittungstext V="QText"/>
20
+ <go:langtext>Langtext der Leistung.</go:langtext>
21
+ <go:kap_bez V="01" DN="Kapitel 01">
22
+ <go:bereich DN="Bereich A"/>
23
+ <go:kapitel DN="Kapitelname"/>
24
+ <go:abschnitt DN="Abschnittname"/>
25
+ </go:kap_bez>
26
+ </go:legende>
27
+ <go:anmerkungen_liste>
28
+ <go:anmerkung>Anmerkung 1</go:anmerkung>
29
+ </go:anmerkungen_liste>
30
+ <go:bewertung_liste>
31
+ <go:bewertung V="196" U="PUNKTE">
32
+ <go:leistung_typ V="GKV"/>
33
+ </go:bewertung>
34
+ </go:bewertung_liste>
35
+ </go:allgemein>
36
+ <go:regel>
37
+ <go:ausschluss_liste>
38
+ <go:bezugsraum>
39
+ <go:gnr_liste>
40
+ <go:gnr V="01101" DN="Ausschluss eins"/>
41
+ </go:gnr_liste>
42
+ </go:bezugsraum>
43
+ </go:ausschluss_liste>
44
+ </go:regel>
45
+ <go:vdx>
46
+ <go:gkv_kontenart_liste>
47
+ <go:gkv_kontenart V="A"/>
48
+ </go:gkv_kontenart_liste>
49
+ </go:vdx>
50
+ </go:gnr>
51
+ </go:gnr_liste>
52
+ </ehd:body>
53
+ </ehd:root>
54
+ """,
55
+ encoding="utf-8",
56
+ )
57
+
58
+ df = parse_ebm_xml_to_dataframe(str(xml))
59
+ assert len(df) == 1
60
+ row = df.iloc[0]
61
+ assert row["code"] == "01100"
62
+ assert row["short_text"] == "Unvorhergesehene Inanspruchnahme I"
63
+ assert row["points"] == "196"
64
+ assert row["notes"] == ["Anmerkung 1"]
65
+ assert row["fachgruppen"] == []
66
+ assert row["exclusions"][0]["code"] == "01101"
67
+
tests/test_retrieval.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from src.chunking import EbmDocument
6
+ from src.vector_store import EbmVectorStore
7
+ from src.retriever import EbmRetriever
8
+
9
+
10
+ class DummyEmbeddingModel:
11
+ model_name = "dummy"
12
+
13
+ def encode(self, texts):
14
+ vectors = []
15
+ for text in texts:
16
+ lower = text.lower()
17
+ vectors.append(
18
+ np.array(
19
+ [
20
+ 1.0 if "01100" in lower else 0.0,
21
+ 1.0 if "inanspruchnahme" in lower else 0.0,
22
+ 1.0 if "vorsorge" in lower else 0.0,
23
+ ],
24
+ dtype=np.float32,
25
+ )
26
+ )
27
+ arr = np.vstack(vectors)
28
+ norm = np.linalg.norm(arr, axis=1, keepdims=True)
29
+ norm[norm == 0] = 1.0
30
+ return arr / norm
31
+
32
+
33
+ def test_retrieval_ranks_relevant_code() -> None:
34
+ docs = [
35
+ EbmDocument(
36
+ code="01100",
37
+ title="Unvorhergesehene Inanspruchnahme I",
38
+ short_text="Unvorhergesehene Inanspruchnahme I",
39
+ receipt_text=None,
40
+ long_text="Notfallversorgung",
41
+ chapter_code=None,
42
+ chapter_name="Kapitel A",
43
+ bereich=None,
44
+ kapitel=None,
45
+ abschnitt=None,
46
+ notes=[],
47
+ points=196,
48
+ fachgruppen=[],
49
+ exclusions=[],
50
+ gkv_account_types=[],
51
+ ),
52
+ EbmDocument(
53
+ code="01732",
54
+ title="Vorsorge",
55
+ short_text="Vorsorge",
56
+ receipt_text=None,
57
+ long_text="Vorsorgeleistung",
58
+ chapter_code=None,
59
+ chapter_name="Kapitel B",
60
+ bereich=None,
61
+ kapitel=None,
62
+ abschnitt=None,
63
+ notes=[],
64
+ points=100,
65
+ fachgruppen=[],
66
+ exclusions=[],
67
+ gkv_account_types=[],
68
+ ),
69
+ ]
70
+
71
+ store, _ = EbmVectorStore.build(docs, embedding_model=DummyEmbeddingModel())
72
+ retriever = EbmRetriever(store, embedding_model=DummyEmbeddingModel())
73
+ results = retriever.retrieve("Was bedeutet 01100?", top_k=1)
74
+ assert results[0]["code"] == "01100"
75
+