From 59e6ce20efe5c49f4898b0a09eadeffa1338e09e Mon Sep 17 00:00:00 2001 From: rl0007 Date: Tue, 11 Aug 2026 08:39:57 +0530 Subject: [PATCH 01/14] feat(rag): lancedb retrieval, cited answers, and pdf fidelity fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a retrieval layer and an Ask/RAG Lab UI over parsed documents, and fixes several pipeline defects found while running two real ICAI study PDFs through it. Retrieval (wikify/rag/) - LanceDB store with local model2vec embeddings (256d, no API key, no server). - Four modes: vector, full-text, hybrid (RRF), and filter. Filter returns EVERY match — "give me all X" is a metadata question, not a similarity guess. - Contextual-retrieval prefix on embed text, parent-section expansion, optional LLM rerank that degrades to fusion order when unavailable. - Permissions are a LanceDB pre-filter, never a post-hoc trim. An omitted ACL decision throws rather than searching everything. Answering - Intent router (exhaustive/semantic/hybrid) with follow-up rewriting; the decision and its plain-language reason are shown in the UI. - Answers cite [n] markers; each citation carries a resolved page, line span and verbatim quote, verified against the source before display. Verification is two gates: fuzzy prose match AND exact equality on figures, signs and statutory refs — a flipped (+)/(-) previously passed a 0.85 similarity check. - Honest refusal when nothing retrieved clears the bar. - Per-question cost and token usage returned and displayed. - Conversations persist to Wikify Ask Session/Message, so route decisions and citations become an evaluation set. Pipeline fixes - Sectioning stopped at the first heading longer than the 140-char title column, after the old tree was deleted — silently dropping 93.6% of a 236-page document. Titles are now clipped; coverage went 6.4% -> 99.8%. - Page classification gated on `chars < 250 AND drawings > 40`, which no born-digital diagram page can satisfy; replaced with per-region detection (1/236 -> 231/236 pages routed correctly). - Tables were being encoded as mermaid flowcharts, destroying row-to-rate correspondence; they now emit HTML tables, and diagrams are parsed and repaired (quoting node labels, `&` chains) before storage. - Page verdicts were frozen at the baseline parse, so remediated pages still read "review"; they now track the adopted content, with a backfill patch. - Remediation could adopt a near-empty candidate over a good one. - Page edits now propagate into the sections and index built from them. Frontend - /ask and /rag-lab, with sources rendered before the answer, citation chips, provenance, and a naive-vs-routed comparison that shows what similarity search missed. Unranked results no longer render a meaningless full score bar. - Mobile support across the app: shell navigation, drill-down replacements for desktop splits, and graph views that degrade to a list rather than an unreadable canvas. Evaluation - 12 golden questions with recall, precision and completeness, plus an HTML scorecard. Routed retrieval beats naive 66% -> 87-90% recall on the demo corpus; routing is an LLM call, so treat the figure as a range. --- docs/rag-explained.md | 313 ++++ frontend/package.json | 1 + frontend/src/components/AgentChatPanel.vue | 15 +- frontend/src/components/AppShell.vue | 98 +- frontend/src/components/Explore.vue | 37 +- frontend/src/components/GraphView.vue | 176 +- frontend/src/components/MarkdownPreview.vue | 33 +- frontend/src/components/NewImportDialog.vue | 10 +- frontend/src/components/PageReview.vue | 85 +- frontend/src/components/ProjectSettings.vue | 8 +- frontend/src/components/SectionTree.vue | 68 +- frontend/src/components/TypeChip.vue | 6 +- frontend/src/components/WikiGenerate.vue | 21 +- frontend/src/components/WikiPreview.vue | 55 +- frontend/src/components/rag/CostMeter.vue | 58 + frontend/src/components/rag/CoverageBar.vue | 47 + .../src/components/rag/EvalScoreboard.vue | 53 + .../src/components/rag/IndexStatusCard.vue | 109 ++ frontend/src/components/rag/RelevanceTag.vue | 94 + frontend/src/components/rag/RouteBadge.vue | 88 + frontend/src/components/rag/SourceCard.vue | 259 +++ frontend/src/composables/useMediaQuery.js | 34 + frontend/src/composables/useRag.js | 331 ++++ frontend/src/pages/AskWiki.vue | 296 +++ frontend/src/pages/ExploreGlobal.vue | 48 +- frontend/src/pages/ImportDetail.vue | 57 +- frontend/src/pages/ImportGraph.vue | 4 +- frontend/src/pages/ImportList.vue | 30 +- frontend/src/pages/ProjectDetail.vue | 22 +- frontend/src/pages/ProjectGraph.vue | 2 +- frontend/src/pages/ProjectList.vue | 20 +- frontend/src/pages/RagLab.vue | 338 ++++ frontend/src/router.js | 10 + frontend/src/utils/actionButton.js | 7 + frontend/src/utils/mermaid.css | 32 + frontend/src/utils/mermaid.js | 103 +- frontend/yarn.lock | 666 ++++++- pyproject.toml | 2 + wikify/agent/llm.py | 9 +- wikify/agent/registry.py | 4 +- wikify/agent/tools/retrieve.py | 94 + wikify/api/ask_history.py | 31 + wikify/api/rag.py | 262 +++ wikify/engine/diagrams.py | 337 ++++ wikify/engine/loader/sectionizer.py | 64 +- wikify/engine/parsers/vlm.py | 53 +- wikify/engine/pdf_utils.py | 30 +- wikify/engine/regions.py | 398 ++++ wikify/engine/remediate.py | 93 +- wikify/engine/reparse.py | 12 +- wikify/engine/store.py | 10 +- wikify/engine/verify/__init__.py | 4 +- wikify/engine/verify/harness.py | 7 +- wikify/hooks.py | 22 +- wikify/patches.txt | 1 + .../v1_0/backfill_canonical_verdict.py | 45 + wikify/rag/__init__.py | 13 + wikify/rag/answer.py | 228 +++ wikify/rag/chunk.py | 404 ++++ wikify/rag/embed.py | 46 + wikify/rag/eval.py | 831 +++++++++ wikify/rag/events.py | 248 +++ wikify/rag/evidence.py | 537 ++++++ wikify/rag/history.py | 305 +++ wikify/rag/index.py | 135 ++ wikify/rag/router.py | 208 +++ wikify/rag/search.py | 424 +++++ wikify/rag/store.py | 103 + wikify/rag/usage.py | 58 + wikify/seed.py | 37 +- wikify/tests/fixtures/__init__.py | 0 wikify/tests/fixtures/demo_corpus.py | 1653 +++++++++++++++++ .../tests/fixtures/icai_page_11_canonical.md | 35 + wikify/tests/test_ask_history.py | 273 +++ wikify/tests/test_canonical_verdict.py | 108 ++ wikify/tests/test_diagrams.py | 208 +++ wikify/tests/test_evidence.py | 371 ++++ wikify/tests/test_page_propagation.py | 242 +++ wikify/tests/test_rag_api.py | 558 ++++++ wikify/tests/test_rag_core.py | 636 +++++++ wikify/tests/test_rag_eval.py | 305 +++ wikify/tests/test_regions.py | 104 ++ wikify/tests/test_remediate_adoption.py | 92 + wikify/tests/test_sectionize.py | 88 +- .../doctype/source_page/source_page.json | 2 +- .../doctype/wikify_ask_message/__init__.py | 0 .../wikify_ask_message.json | 190 ++ .../wikify_ask_message/wikify_ask_message.py | 10 + .../doctype/wikify_ask_session/__init__.py | 0 .../wikify_ask_session.json | 137 ++ .../wikify_ask_session/wikify_ask_session.py | 17 + 91 files changed, 13385 insertions(+), 333 deletions(-) create mode 100644 docs/rag-explained.md create mode 100644 frontend/src/components/rag/CostMeter.vue create mode 100644 frontend/src/components/rag/CoverageBar.vue create mode 100644 frontend/src/components/rag/EvalScoreboard.vue create mode 100644 frontend/src/components/rag/IndexStatusCard.vue create mode 100644 frontend/src/components/rag/RelevanceTag.vue create mode 100644 frontend/src/components/rag/RouteBadge.vue create mode 100644 frontend/src/components/rag/SourceCard.vue create mode 100644 frontend/src/composables/useMediaQuery.js create mode 100644 frontend/src/composables/useRag.js create mode 100644 frontend/src/pages/AskWiki.vue create mode 100644 frontend/src/pages/RagLab.vue create mode 100644 frontend/src/utils/actionButton.js create mode 100644 frontend/src/utils/mermaid.css create mode 100644 wikify/agent/tools/retrieve.py create mode 100644 wikify/api/ask_history.py create mode 100644 wikify/api/rag.py create mode 100644 wikify/engine/diagrams.py create mode 100644 wikify/engine/regions.py create mode 100644 wikify/patches/v1_0/backfill_canonical_verdict.py create mode 100644 wikify/rag/__init__.py create mode 100644 wikify/rag/answer.py create mode 100644 wikify/rag/chunk.py create mode 100644 wikify/rag/embed.py create mode 100644 wikify/rag/eval.py create mode 100644 wikify/rag/events.py create mode 100644 wikify/rag/evidence.py create mode 100644 wikify/rag/history.py create mode 100644 wikify/rag/index.py create mode 100644 wikify/rag/router.py create mode 100644 wikify/rag/search.py create mode 100644 wikify/rag/store.py create mode 100644 wikify/rag/usage.py create mode 100644 wikify/tests/fixtures/__init__.py create mode 100644 wikify/tests/fixtures/demo_corpus.py create mode 100644 wikify/tests/fixtures/icai_page_11_canonical.md create mode 100644 wikify/tests/test_ask_history.py create mode 100644 wikify/tests/test_canonical_verdict.py create mode 100644 wikify/tests/test_diagrams.py create mode 100644 wikify/tests/test_evidence.py create mode 100644 wikify/tests/test_page_propagation.py create mode 100644 wikify/tests/test_rag_api.py create mode 100644 wikify/tests/test_rag_core.py create mode 100644 wikify/tests/test_rag_eval.py create mode 100644 wikify/tests/test_regions.py create mode 100644 wikify/tests/test_remediate_adoption.py create mode 100644 wikify/wikify/doctype/wikify_ask_message/__init__.py create mode 100644 wikify/wikify/doctype/wikify_ask_message/wikify_ask_message.json create mode 100644 wikify/wikify/doctype/wikify_ask_message/wikify_ask_message.py create mode 100644 wikify/wikify/doctype/wikify_ask_session/__init__.py create mode 100644 wikify/wikify/doctype/wikify_ask_session/wikify_ask_session.json create mode 100644 wikify/wikify/doctype/wikify_ask_session/wikify_ask_session.py diff --git a/docs/rag-explained.md b/docs/rag-explained.md new file mode 100644 index 0000000..75900d3 --- /dev/null +++ b/docs/rag-explained.md @@ -0,0 +1,313 @@ +# RAG, explained — and how Wikify's actually works + +A from-scratch explanation, written against the real system on `wikify.localhost`. +Every number here was measured, not estimated. + +--- + +## 1. The problem RAG solves + +A language model only knows what it read during training. It has never seen your ICAI +booklet. There are three ways to fix that: + +| Approach | Why it fails here | +|---|---| +| Retrain the model on your documents | Expensive, slow, and it still can't tell you **where** an answer came from | +| Paste the whole document into every question | Your ICAI PDF is **528,336 characters**. Too big, too slow, and models get measurably worse at finding details in a huge pile | +| **Retrieve only the relevant bits, then ask** | ← this is RAG | + +**RAG = Retrieval-Augmented Generation.** It is genuinely only two steps: + +1. **Retrieve** — find the handful of passages that could answer this question. +2. **Generate** — hand *only* those passages to the model and say "answer using these, + and cite them." + +Everything else in this document is engineering to make step 1 actually work. **Step 1 is +where RAG systems live or die.** If retrieval hands over the wrong pages, no model on +earth can save the answer. + +--- + +## 2. Three different ways to "find" — and when each fails + +This is the part most people skip, and it's the part that matters. + +### Vector search (semantic) + +Text is converted into a list of numbers — a **vector** — that encodes *meaning*. Similar +meanings land close together in that space, so you find answers by measuring distance. + +Real example from this codebase: searching *"who works on the UI"* correctly returned a +chunk about a **Frontend Engineer (Vue, Vite)** — sharing **not one word** with the query. + +- **Good at:** paraphrase, synonyms, fuzzy "tell me about…" questions. +- **Fails at:** exact tokens. It may happily blur `194-I` and `194-J`. For tax law that is + not a small error. +- **Also fails at:** completeness. It returns the top *k* most similar. It has no idea + whether the right answer is 3 items or 300. + +### Keyword search (BM25 / full-text) + +Classic text matching, the way search engines worked before embeddings. + +- **Good at:** `115BAC`, `₹2,40,000`, section numbers, proper nouns, acronyms. +- **Fails at:** paraphrase. Asking "how much extra tax do the rich pay" won't match a + heading that says "SURCHARGE". + +### Metadata filter + +Not search at all — a database `WHERE` clause. +`section_type = 'job_description'` → returns **every** matching section, guaranteed. + +- **Good at:** completeness. Exhaustive by construction. +- **Fails at:** anything requiring understanding of meaning. + +### Why this matters — the measured proof + +Someone asks: *"give me all the job descriptions across all the documents."* + +| Method | Found | Documents covered | Recall | +|---|---|---|---| +| Naive top-8 vector search | 8 sections (only 6 correct) | 4 of 5 | **40%** | +| Metadata filter | **15 sections** | **5 of 5** | **100%** | + +Vector search **missed 9 of 15 sections and an entire document** — and, worse, reported no +sign that anything was missing. It looked confident. + +> **The core insight:** "give me all X" is an *exhaustive* question. It's a database query +> wearing a search query's clothes. Answering it with similarity is guessing. +> Similarity is for "tell me about Y". + +The `/rag-lab` page exists purely to make this visible — it runs both and highlights what +naive retrieval missed. + +### Hybrid + RRF + +In practice you run vector **and** keyword together and fuse the two ranked lists with +**Reciprocal Rank Fusion** — a simple rule that rewards documents ranking well in either +list. Nobody serious ships pure vector search. + +⚠️ **Gotcha we hit:** RRF scores are derived from *rank position*, not confidence. An +on-topic and a nonsense query can produce nearly identical RRF scores. So you **cannot** +use an RRF score as a "is this relevant enough to answer?" threshold. We learned this the +hard way and moved the refusal check onto the reranker instead. + +--- + +## 3. The ingestion pipeline (document → searchable) + +``` +PDF (236 pages, 528,336 chars) + │ + │ 1. PARSE + │ Each page → markdown. Text-layer pages parse cheaply; diagram-heavy + │ pages go to a vision model. Stored on Source Page.canonical_markdown + ▼ +Source Page × 236 + │ + │ 2. SECTION + │ Pages → a *tree* of meaningful units: "Basic Concepts" → "Rates of + │ Tax" → "Surcharge". Each carries a title, page range, and type. + ▼ +Source Section × 296 ← 44 before we fixed the page-19 bug + │ + │ 3. CHUNK + │ Sections → ~1,200-character pieces with ~150 overlap, split on + │ heading/paragraph boundaries. Each piece remembers its parent. + ▼ +Chunk × 648 + │ + │ 4. EMBED + │ Each chunk → a 256-number vector (model2vec potion-base-8M). + │ Runs locally. No API key. No GPU. Free. + ▼ + │ 5. INDEX + │ Vectors + text + metadata → LanceDB: an embedded database that is + │ just a directory on disk. No server to run. + ▼ +sites/wikify.localhost/private/files/wikify_lance +``` + +### Why chunk at all? + +Two reasons. Precision — a 40-page section is mostly irrelevant to any one question, and +embedding it produces a vague "average" vector that matches nothing well. And limits — +models have a finite context window. + +**But** chunks are bad to *read* (they start mid-thought). So we use **parent-document +retrieval**: search the small chunks for precision, then hand the model the **full parent +section** for context. Best of both. + +--- + +## 4. The query pipeline (question → cited answer) + +``` +"what is the TDS rate on rent under section 194-I" + │ + │ 1. ROUTE — a cheap LLM call + │ Is this exhaustive ("all X" → filter) or semantic ("about Y" → search)? + │ Also rewrites follow-ups: "what about the second one?" → standalone. + │ ➜ shown in the UI as the route badge, with its reason in plain words + ▼ + │ 2. RETRIEVE + │ Vector + keyword, fused by RRF. Metadata and permissions are applied + │ as a WHERE clause *inside* the query — a pre-filter, never a + │ post-filter (see §6). + ▼ + │ 3. RERANK — a second, cheap LLM pass + │ Fetch ~50 candidates, score each against the question, keep the best 8. + │ Biggest single quality jump after hybrid search. + ▼ + │ 4. EXPAND + │ Swap each chunk for its full parent section. + ▼ + │ 5. SYNTHESISE + │ "Answer using ONLY these excerpts. Cite each claim. If they don't + │ contain the answer, say so." + ▼ +Answer + [1][2][3] + page numbers + cost +``` + +Real output from this exact query (previously **impossible** — page 154 wasn't indexed): + +``` +HTTP 200 · 17.3s · $0.0024 + 2% — plant & machinery or equipment [3] + 10% — land, building, furniture or fittings [3] + threshold > ₹2,40,000 in a F.Y. [3] +``` + +--- + +## 5. Design choices that aren't standard RAG + +**Contextual retrieval.** Before embedding, each chunk is prefixed with +`document title › section path`. A chunk reading *"10% for land and building"* is +meaningless in isolation; with its breadcrumb it becomes findable. Anthropic measured +~35% fewer retrieval failures from this technique. Most teams pay an LLM call per chunk to +generate that context — we got it free because the section tree already existed. + +Crucially the prefix goes into `embed_text` (what gets embedded) and **not** `text` (what +gets displayed). The user never sees it. + +**Filter-first routing.** §2's thesis, implemented. + +**Page-level citations.** Most RAG can cite a *document*. Ours cites `p. 154`, because +sections carry page ranges. That's what makes an answer checkable against the source PDF. + +**Honest refusal.** Below a confidence bar it says "I couldn't find this in the wiki" +rather than inventing an answer. A confident wrong answer about a tax rate is worse than +no answer. + +**Permission-aware retrieval.** See §6 — it's the subtlest thing here. + +--- + +## 6. Two traps worth understanding + +### Pre-filter vs post-filter (a real security bug we fixed) + +```python +# WRONG — post-filter +hits = search(query, limit=10) +hits = [h for h in hits if h.project in allowed] # may return 0 of 10 + +# RIGHT — pre-filter +hits = search(query, limit=10, where="project IN (...)") +``` + +Post-filtering searches everything and *then* removes what you can't see — so you might +get 10 results, discard 9, and show 1. Worse, forbidden content passed through the +process. Pre-filtering means the database never considers it. + +We shipped a real fail-open bug here: the "no ACL restriction" sentinel was `None`, which +is *also* what a forgotten argument looks like. Calling `answer()` with default arguments +leaked **15 citations** to a user with no read permission. Fix: make "search everything" +a distinct object that you must pass deliberately, and reject `None` outright. + +> **Lesson:** never let "I forgot" and "I meant to allow everything" be spelled the same +> way. + +### Fuzzy matching on numbers + +When verifying that a quoted citation really appears in the source, fuzzy matching is +right for prose and **catastrophic** for numbers. We measured: a flipped `(+)` → `(-)` +sign scored **0.85 similarity** and passed as verified. A wrong digit failed only by +threshold luck. + +For exam prep, a citation that *renders as verified* while containing a wrong rate is +worse than no citation — it manufactures trust. Fix: fuzzy for prose, **exact +character-for-character** for digits, percentages, currency, signs, and statutory +references. A digit is either right or it isn't; there is no "85% right". + +--- + +## 7. Where everything lives + +| Concept | File | +|---|---| +| Text → vectors | `wikify/rag/embed.py` | +| LanceDB connection + schema | `wikify/rag/store.py` | +| Sections → chunks (+ contextual prefix) | `wikify/rag/chunk.py` | +| Build / refresh the index | `wikify/rag/index.py` | +| The four search modes + RRF + ACL | `wikify/rag/search.py` | +| Intent routing + query rewriting | `wikify/rag/router.py` | +| Synthesis, citations, refusal | `wikify/rag/answer.py` | +| Citation verification | `wikify/rag/evidence.py` | +| Whitelisted endpoints | `wikify/api/rag.py` | +| Quality measurement | `wikify/rag/eval.py` | +| Ask UI / proof UI | `frontend/src/pages/AskWiki.vue`, `RagLab.vue` | + +**Four search modes** in `search.py`: + +| Mode | What it does | Use for | +|---|---|---| +| `vector` | Pure semantic similarity | "tell me about…" | +| `fts` | Keyword / BM25 | exact codes, section numbers | +| `hybrid` | Both, fused by RRF | the sensible default | +| `filter` | Metadata `WHERE`, **no top-k limit** | "give me ALL X" | + +--- + +## 8. What measured quality looks like + +You cannot improve what you don't measure. `rag/eval.py` runs 12 golden questions with +known-correct answers and reports: + +- **recall@k** — of the sources that *should* have been found, how many were? +- **precision@k** — of what was returned, how much was actually relevant? +- **completeness** — did we return **all** expected sources? (the one that matters for + "all X") + +Current results on the demo corpus: + +| | naive | routed | +|---|---|---| +| Mean recall | 66% | **87–90%** | +| Completeness | 45% | **73–82%** | + +⚠️ Routed numbers are a **range**, not a point, because routing is an LLM call and +therefore non-deterministic. Quote it honestly. + +On the real ICAI document, graded against the source PDF: **7 of 12 correct**. Failure +modes worth knowing: a false refusal when the reranker misfired, and a fabricated category +caused by a mind-map diagram being flattened into a bullet list — the structure was lost +at *parse* time, long before retrieval ran. + +> **Lesson:** most RAG quality problems are actually **ingestion** problems. Our single +> biggest win today wasn't a retrieval tweak — it was discovering that a 168-character +> heading exceeded a 140-character database column, which threw an exception mid-loop and +> silently dropped every section after page 19. That one bug hid **93.6% of the document** +> from search. Coverage went from 6.4% to 99.8% with no re-parsing and zero LLM cost. + +--- + +## 9. The one-paragraph version + +Slice documents into meaningful pieces. Store each piece as coordinates in "meaning space" +alongside its metadata — page numbers, type, permissions. When a question arrives, work out +what *kind* of question it is, fetch the right pieces (by meaning, by keyword, or by +database filter), hand **only** those to the model, and make it cite them. The thing that +separates a good RAG system from a demo is knowing when **not** to use the clever +similarity search — and measuring yourself honestly enough to find out. diff --git a/frontend/package.json b/frontend/package.json index 82953d4..6adb2f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "frappe-ui": "1.0.0-beta.24", + "mermaid": "^11.16.1", "pdfjs-dist": "^4.7.76", "socket.io-client": "^4.7.2", "splitpanes": "^3.1.5", diff --git a/frontend/src/components/AgentChatPanel.vue b/frontend/src/components/AgentChatPanel.vue index bec11dc..492c24d 100644 --- a/frontend/src/components/AgentChatPanel.vue +++ b/frontend/src/components/AgentChatPanel.vue @@ -38,10 +38,19 @@ function saveGeo() { localStorage.setItem(GEO_KEY, JSON.stringify({ ...geo })); } +// A geometry saved on a wide screen would otherwise stay wider than a narrow viewport the +// window is later opened in — a fixed element that overhangs the viewport drags the whole +// page into horizontal scroll, and its close button can land off-screen. +function fitToViewport() { + Object.assign(geo, clamped(geo)); +} +window.addEventListener("resize", fitToViewport); +onBeforeUnmount(() => window.removeEventListener("resize", fitToViewport)); + const windowStyle = computed(() => isPhone.value ? {} - : { left: `${geo.x}px`, top: `${geo.y}px`, width: `${geo.w}px`, height: `${geo.h}px` } + : { left: `${geo.x}px`, top: `${geo.y}px`, width: `${geo.w}px`, height: `${geo.h}px` }, ); const minimized = ref(false); @@ -136,14 +145,14 @@ async function refreshSessions() { // Model picker — populated from get_agent_models when the panel first opens. const modelOptions = computed(() => - (models.value || []).map((m) => ({ label: shortModel(m), onClick: () => (model.value = m) })) + (models.value || []).map((m) => ({ label: shortModel(m), onClick: () => (model.value = m) })), ); watch( () => props.open, (open) => { if (open) chat.loadModels(); }, - { immediate: true } + { immediate: true }, ); // Rename dialog. diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index a6107a5..0d47bd7 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -1,7 +1,6 @@