Agent Template

New Hire Handbook Grounded Q&A

A document-grounded onboarding assistant that lets new hires upload one or more company handbooks, policy PDFs, or text files, then ask questions answered strictly from those documents with source citations and clear coverage gaps.

onboarding employee handbook policy qa pdf grounded answers citations
ozzo Jul 13, 2026 1 use

Preview Mode

This is a preview with sample data. The template uses placeholders like which will be replaced with actual agent data.

About This Template

New Hire Handbook Grounded Q&A is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.

Topics onboarding employee handbook policy qa pdf grounded answers citations
Template Preview

Template Metadata

Slug
new-hire-handbook-grounded-qa
Created By
ozzo
Created
Jul 13, 2026
Usage Count
1

Tags

onboarding employee handbook policy qa pdf grounded answers citations

Code Statistics

HTML Lines
87
CSS Lines
321
JS Lines
282
Python Lines
141

Source Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>{{ agent_name }}</title>
  <style>{{ css_code }}</style>
</head>
<body>
  <div class="app-shell">
    <header class="hero">
      <div>
        <div class="eyebrow">📘 New Hire Onboarding Assistant</div>
        <h1>{{ agent_name }}</h1>
        <p class="subtitle">Upload your company handbook and policy documents, then ask grounded questions with document citations.</p>
      </div>
      <div class="status-card" id="status-card">
        <div class="status-label">Agent status</div>
        <div class="status-value" id="agent-status">Initializing…</div>
      </div>
    </header>

    <main class="workspace-grid">
      <section class="panel upload-panel">
        <div class="panel-header">
          <h2>📂 Policy Library</h2>
          <p>Add one or more handbook or benefits documents.</p>
        </div>

        <div class="upload-box">
          <label for="file-input" class="upload-label">Choose employee handbook files</label>
          <input id="file-input" type="file" accept=".pdf,.txt,.md" multiple />
          <button id="upload-btn" class="secondary-btn">Add documents to library</button>
        </div>

        <div id="upload-error" class="inline-error hidden"></div>
        <div id="upload-success" class="inline-success hidden"></div>

        <div class="doc-toolbar">
          <span class="doc-count" id="doc-count">0 documents loaded</span>
          <button id="clear-btn" class="ghost-btn">Clear library</button>
        </div>

        <div id="doc-list" class="doc-list"></div>
      </section>

      <section class="panel qa-panel">
        <div class="panel-header">
          <h2>💬 Handbook Questions</h2>
          <p>Ask policy questions grounded only in the uploaded documents.</p>
        </div>

        <form id="qa-form" class="qa-form">
          <label for="question-input" class="input-label">Ask an onboarding or policy question</label>
          <textarea id="question-input" rows="5" placeholder="Example: What does the handbook say about vacation accrual for full-time employees?"></textarea>
          <div id="question-error" class="inline-error hidden"></div>
          <div class="form-actions">
            <button id="ask-btn" type="submit" class="primary-btn">Answer from handbook</button>
          </div>
        </form>

        <div id="results-container" class="results-container">
          <div class="empty-state" id="empty-state">
            <div class="empty-emoji">🧭</div>
            <h3>Your onboarding library is ready for questions</h3>
            <ul>
              <li>Upload multiple documents like a handbook, benefits guide, and expense policy</li>
              <li>Get answers grounded strictly in the uploaded text</li>
              <li>See which document each answer came from</li>
              <li>Get a clear note when a topic is not covered</li>
            </ul>
          </div>
        </div>
      </section>
    </main>
  </div>

  <div id="loading-overlay" class="loading-overlay hidden" aria-live="polite">
    <div class="spinner-card">
      <div class="spinner"></div>
      <div id="loading-text">Working with your handbook…</div>
    </div>
  </div>

  <script>{{ js_code }}</script>
</body>
</html>
:root {
  --bg: #f4f7fb;
  --surface: #ffffff;
  --surface-alt: #f8fafc;
  --text: #162033;
  --muted: #5d6b82;
  --primary: #2563eb;
  --primary-strong: #1d4ed8;
  --accent: #0f766e;
  --border: #dbe4f0;
  --danger: #c62828;
  --danger-bg: #fff1f1;
  --success: #166534;
  --success-bg: #effaf3;
  --shadow: 0 14px 32px rgba(21, 33, 58, 0.08);
  --radius: 18px;
  --radius-sm: 12px;
  --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: linear-gradient(180deg, #eef4ff 0%, var(--bg) 100%); color: var(--text); font-family: var(--font); }
body { min-height: 100vh; }
.hidden { display: none !important; }

.app-shell {
  max-width: 1440px;
  margin: 0 auto;
  padding: 28px;
}

.hero {
  display: flex;
  justify-content: space-between;
  gap: 20px;
  align-items: flex-start;
  margin-bottom: 24px;
}

.eyebrow {
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--accent);
  margin-bottom: 10px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

h1 {
  margin: 0;
  font-size: clamp(2rem, 4vw, 3rem);
  line-height: 1.05;
}

.subtitle {
  margin: 12px 0 0;
  color: var(--muted);
  max-width: 780px;
  font-size: 1.02rem;
}

.status-card, .panel, .spinner-card {
  background: var(--surface);
  border: 1px solid rgba(219, 228, 240, 0.9);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.status-card {
  min-width: 240px;
  padding: 16px 18px;
}

.status-label {
  color: var(--muted);
  font-size: 0.82rem;
  margin-bottom: 6px;
}

.status-value {
  font-weight: 700;
}

.workspace-grid {
  display: grid;
  grid-template-columns: 420px minmax(0, 1fr);
  gap: 24px;
}

.panel {
  padding: 22px;
}

.panel-header h2 {
  margin: 0 0 6px;
  font-size: 1.3rem;
}

.panel-header p {
  margin: 0 0 18px;
  color: var(--muted);
}

.upload-box {
  background: var(--surface-alt);
  border: 1px dashed var(--border);
  border-radius: var(--radius-sm);
  padding: 18px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.upload-label, .input-label {
  font-weight: 650;
  font-size: 0.95rem;
}

input[type="file"], textarea {
  width: 100%;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: #fff;
  color: var(--text);
  font: inherit;
}

input[type="file"] {
  padding: 12px;
}

textarea {
  resize: vertical;
  min-height: 130px;
  padding: 14px 15px;
  line-height: 1.5;
}

.primary-btn, .secondary-btn, .ghost-btn {
  font: inherit;
  border: none;
  border-radius: 12px;
  padding: 12px 16px;
  cursor: pointer;
  font-weight: 700;
  transition: transform 0.15s ease, background 0.2s ease, opacity 0.2s ease;
}

.primary-btn { background: var(--primary); color: white; }
.primary-btn:hover { background: var(--primary-strong); }
.secondary-btn { background: #e8f0ff; color: var(--primary-strong); }
.ghost-btn { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.primary-btn:hover, .secondary-btn:hover, .ghost-btn:hover { transform: translateY(-1px); }
.primary-btn:disabled, .secondary-btn:disabled, .ghost-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
  transform: none;
}

button:focus-visible, textarea:focus-visible, input:focus-visible {
  outline: 3px solid rgba(37, 99, 235, 0.18);
  outline-offset: 2px;
}

.doc-toolbar {
  margin-top: 18px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.doc-count {
  color: var(--muted);
  font-size: 0.95rem;
}

.doc-list {
  margin-top: 16px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  max-height: 520px;
  overflow: auto;
}

.doc-item {
  background: var(--surface-alt);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 14px;
}

.doc-name {
  font-weight: 700;
  margin-bottom: 6px;
}

.doc-meta {
  color: var(--muted);
  font-size: 0.88rem;
}

.qa-form {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.form-actions {
  display: flex;
  justify-content: flex-end;
}

.results-container {
  margin-top: 22px;
  min-height: 280px;
}

.empty-state {
  background: linear-gradient(180deg, #fcfdff 0%, #f5f8ff 100%);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 28px;
}

.empty-state h3 { margin-top: 0; }
.empty-state ul { margin: 12px 0 0 18px; color: var(--muted); }
.empty-emoji { font-size: 2rem; margin-bottom: 10px; }

.message {
  border-radius: 16px;
  padding: 18px;
  border: 1px solid var(--border);
  background: #fff;
  margin-bottom: 14px;
}

.message.user {
  background: #eef4ff;
}

.message.assistant {
  background: #ffffff;
}

.message-label {
  font-size: 0.8rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  margin-bottom: 8px;
}

.message-body {
  white-space: pre-wrap;
  line-height: 1.58;
}

.inline-error, .inline-success {
  margin-top: 10px;
  padding: 12px 14px;
  border-radius: 12px;
  font-size: 0.94rem;
}

.inline-error {
  background: var(--danger-bg);
  color: var(--danger);
  border: 1px solid rgba(198, 40, 40, 0.18);
}

.inline-success {
  background: var(--success-bg);
  color: var(--success);
  border: 1px solid rgba(22, 101, 52, 0.16);
}

.loading-overlay {
  position: fixed;
  inset: 0;
  background: rgba(244, 247, 251, 0.72);
  backdrop-filter: blur(3px);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.spinner-card {
  padding: 22px 24px;
  display: flex;
  align-items: center;
  gap: 14px;
}

.spinner {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  border: 3px solid #d6e3ff;
  border-top-color: var(--primary);
  animation: spin 0.9s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

@media (max-width: 1200px) {
  .workspace-grid { grid-template-columns: 1fr; }
}

@media (max-width: 768px) {
  .app-shell { padding: 16px; }
  .hero { flex-direction: column; }
  .status-card { width: 100%; }
  .panel { padding: 18px; }
  .form-actions { justify-content: stretch; }
  .form-actions button { width: 100%; }
  .doc-toolbar { flex-direction: column; align-items: stretch; }
}
(() => {
  const state = {
    pyReady: false,
    docs: []
  };

  const els = {};

  function escapeHtml(value) {
    return String(value)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  function cacheEls() {
    els.fileInput = document.getElementById('file-input');
    els.uploadBtn = document.getElementById('upload-btn');
    els.clearBtn = document.getElementById('clear-btn');
    els.docList = document.getElementById('doc-list');
    els.docCount = document.getElementById('doc-count');
    els.uploadError = document.getElementById('upload-error');
    els.uploadSuccess = document.getElementById('upload-success');
    els.qaForm = document.getElementById('qa-form');
    els.questionInput = document.getElementById('question-input');
    els.askBtn = document.getElementById('ask-btn');
    els.questionError = document.getElementById('question-error');
    els.results = document.getElementById('results-container');
    els.emptyState = document.getElementById('empty-state');
    els.overlay = document.getElementById('loading-overlay');
    els.loadingText = document.getElementById('loading-text');
    els.agentStatus = document.getElementById('agent-status');
  }

  function setStatus(text) {
    els.agentStatus.textContent = text;
  }

  function showOverlay(text) {
    els.loadingText.textContent = text;
    els.overlay.classList.remove('hidden');
  }

  function hideOverlay() {
    els.overlay.classList.add('hidden');
  }

  function setBusy(isBusy) {
    els.uploadBtn.disabled = isBusy;
    els.clearBtn.disabled = isBusy;
    els.askBtn.disabled = isBusy;
    els.fileInput.disabled = isBusy;
    els.questionInput.disabled = isBusy;
  }

  function clearMessages() {
    els.uploadError.classList.add('hidden');
    els.uploadError.textContent = '';
    els.uploadSuccess.classList.add('hidden');
    els.uploadSuccess.textContent = '';
    els.questionError.classList.add('hidden');
    els.questionError.textContent = '';
  }

  function showUploadError(msg) {
    els.uploadSuccess.classList.add('hidden');
    els.uploadError.textContent = msg;
    els.uploadError.classList.remove('hidden');
  }

  function showUploadSuccess(msg) {
    els.uploadError.classList.add('hidden');
    els.uploadSuccess.textContent = msg;
    els.uploadSuccess.classList.remove('hidden');
  }

  function showQuestionError(msg) {
    els.questionError.textContent = msg;
    els.questionError.classList.remove('hidden');
  }

  function renderDocList() {
    els.docCount.textContent = `${state.docs.length} document${state.docs.length === 1 ? '' : 's'} loaded`;
    if (!state.docs.length) {
      els.docList.innerHTML = '<div class="doc-item"><div class="doc-name">No policy documents loaded yet</div><div class="doc-meta">Upload a handbook PDF, benefits guide, or text policy file to start grounded Q&amp;A.</div></div>';
      return;
    }
    els.docList.innerHTML = state.docs.map((doc) => {
      const chars = typeof doc.chars === 'number' ? `${doc.chars.toLocaleString()} chars indexed` : 'Indexed';
      return `<div class="doc-item"><div class="doc-name">${escapeHtml(doc.name)}</div><div class="doc-meta">${escapeHtml(doc.kind || 'document')} · ${escapeHtml(chars)}</div></div>`;
    }).join('');
  }

  window.addMessage = function(type, content) {
    if (els.emptyState) {
      els.emptyState.classList.add('hidden');
    }
    const safeType = type === 'user' ? 'user' : 'assistant';
    const wrapper = document.createElement('div');
    wrapper.className = `message ${safeType}`;
    wrapper.innerHTML = `<div class="message-label">${safeType === 'user' ? 'Your question' : 'Grounded answer'}</div><div class="message-body">${escapeHtml(content)}</div>`;
    els.results.appendChild(wrapper);
    els.results.scrollTop = els.results.scrollHeight;
  };

  async function waitForReady() {
    if (!window.pyodideReady) {
      await new Promise((resolve) => {
        document.addEventListener('pyodide-ready', resolve, { once: true });
      });
    }
    if (window.PROVIDER === 'local') {
      if (!window.agentManager || !window.agentManager.isLoaded) {
        setStatus('Load a model from the top bar to begin');
        throw new Error('Please load a local model from the top bar before using the onboarding assistant.');
      }
    }
    state.pyReady = true;
    setStatus('Ready for handbook uploads');
  }

  async function refreshDocuments() {
    const docsJson = await window.pyodide.runPythonAsync('import json\njson.dumps(_list_documents())');
    state.docs = JSON.parse(docsJson || '[]');
    renderDocList();
  }

  async function handleUpload() {
    clearMessages();
    if (!state.pyReady) {
      showUploadError('The onboarding assistant is still initializing. Please wait a moment.');
      return;
    }
    const files = Array.from(els.fileInput.files || []);
    if (!files.length) {
      showUploadError('Choose at least one handbook, benefits, or policy file to upload.');
      return;
    }

    setBusy(true);
    showOverlay('Indexing policy documents…');
    try {
      let added = 0;
      for (const file of files) {
        const lower = file.name.toLowerCase();
        let text = '';
        let fileType = 'text';

        if (lower.endsWith('.pdf')) {
          const buffer = await file.arrayBuffer();
          const bytes = Array.from(new Uint8Array(buffer));
          window.pyodide.globals.set('upload_name', file.name);
          window.pyodide.globals.set('upload_bytes', bytes);
          const resultJson = await window.pyodide.runPythonAsync('import json\njson.dumps(await _add_pdf_document(upload_name, upload_bytes))');
          const result = JSON.parse(resultJson);
          if (!result.ok) {
            throw new Error(result.error || `Failed to process ${file.name}`);
          }
          added += 1;
        } else {
          text = await file.text();
          fileType = lower.endsWith('.md') ? 'markdown' : 'text';
          window.pyodide.globals.set('upload_name', file.name);
          window.pyodide.globals.set('upload_text', text);
          window.pyodide.globals.set('upload_kind', fileType);
          const resultJson = await window.pyodide.runPythonAsync('import json\njson.dumps(await _add_text_document(upload_name, upload_text, upload_kind))');
          const result = JSON.parse(resultJson);
          if (!result.ok) {
            throw new Error(result.error || `Failed to process ${file.name}`);
          }
          added += 1;
        }
      }
      await refreshDocuments();
      els.fileInput.value = '';
      showUploadSuccess(`Added ${added} onboarding document${added === 1 ? '' : 's'} to the library.`);
      setStatus('Documents indexed and ready for questions');
    } catch (err) {
      showUploadError(err && err.message ? err.message : 'Unable to upload the selected policy documents.');
    } finally {
      hideOverlay();
      setBusy(false);
    }
  }

  async function handleAsk(event) {
    event.preventDefault();
    clearMessages();

    if (!state.pyReady) {
      showQuestionError('The onboarding assistant is still initializing. Please wait a moment.');
      return;
    }
    if (!state.docs.length) {
      showQuestionError('Upload at least one employee handbook or policy document before asking a question.');
      return;
    }

    const question = els.questionInput.value.trim();
    if (!question) {
      showQuestionError('Enter an onboarding or policy question to search the uploaded handbook.');
      return;
    }

    if (els.emptyState) {
      els.emptyState.classList.add('hidden');
    }
    window.addMessage('user', question);

    setBusy(true);
    showOverlay('Reviewing uploaded handbook documents…');
    setStatus('Answering from uploaded documents');

    try {
      window.pyodide.globals.set('user_query', question);
      const answer = await window.pyodide.runPythonAsync('await process_user_query(user_query)');
      window.addMessage('assistant', answer);
      setStatus('Ready for the next onboarding question');
    } catch (err) {
      window.addMessage('assistant', `Sorry — I couldn\'t answer from the uploaded documents. ${err && err.message ? err.message : 'Please try again.'}`);
      setStatus('Error while answering');
    } finally {
      hideOverlay();
      setBusy(false);
    }
  }

  async function handleClear() {
    clearMessages();
    if (!state.pyReady) {
      showUploadError('The onboarding assistant is still initializing. Please wait a moment.');
      return;
    }
    setBusy(true);
    showOverlay('Clearing onboarding library…');
    try {
      await window.pyodide.runPythonAsync('await _clear_all_documents()');
      state.docs = [];
      renderDocList();
      els.results.innerHTML = '<div class="empty-state" id="empty-state"><div class="empty-emoji">🧭</div><h3>Your onboarding library is ready for questions</h3><ul><li>Upload multiple documents like a handbook, benefits guide, and expense policy</li><li>Get answers grounded strictly in the uploaded text</li><li>See which document each answer came from</li><li>Get a clear note when a topic is not covered</li></ul></div>';
      els.emptyState = document.getElementById('empty-state');
      showUploadSuccess('Cleared all uploaded handbook and policy documents.');
      setStatus('Library cleared');
    } catch (err) {
      showUploadError(err && err.message ? err.message : 'Unable to clear the document library.');
    } finally {
      hideOverlay();
      setBusy(false);
    }
  }

  async function init() {
    cacheEls();
    renderDocList();
    els.uploadBtn.addEventListener('click', handleUpload);
    els.clearBtn.addEventListener('click', handleClear);
    els.qaForm.addEventListener('submit', handleAsk);
    els.fileInput.addEventListener('change', () => {
      clearMessages();
    });
    els.questionInput.addEventListener('input', () => {
      els.questionError.classList.add('hidden');
      els.questionError.textContent = '';
    });

    try {
      await waitForReady();
      await refreshDocuments();
    } catch (err) {
      setStatus('Not ready');
      showUploadError(err && err.message ? err.message : 'The onboarding assistant could not initialize.');
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();
import io
import json
from typing import Any

from pypdf import PdfReader

MAX_DOC_CHARS = int("[[[MAX_CHARS|6000]]]")
TOP_K = 5


def _truncate_text(text: str, max_chars: int = MAX_DOC_CHARS) -> str:
    text = (text or "").strip()
    if len(text) <= max_chars:
        return text
    return text[:max_chars] + "\n\n[Truncated for answer budget]"


def _extract_pdf_text(pdf_bytes: list[int]) -> str:
    buffer = io.BytesIO(bytes(pdf_bytes))
    reader = PdfReader(buffer)
    pages = []
    for page in reader.pages:
        try:
            pages.append(page.extract_text() or "")
        except Exception:
            pages.append("")
    return "\n\n".join(pages).strip()


async def _add_text_document(name: str, text: str, kind: str = "text") -> dict[str, Any]:
    clean_name = (name or "Untitled document").strip()
    clean_text = (text or "").strip()
    if not clean_text:
        return {"ok": False, "error": f"{clean_name} appears to be empty."}
    await agentop_rag.add_document(clean_name, clean_text)
    return {
        "ok": True,
        "name": clean_name,
        "kind": kind,
        "chars": len(clean_text),
    }


async def _add_pdf_document(name: str, pdf_bytes: list[int]) -> dict[str, Any]:
    clean_name = (name or "Untitled PDF").strip()
    text = _extract_pdf_text(pdf_bytes)
    if not text:
        return {"ok": False, "error": f"No readable text was found in {clean_name}."}
    await agentop_rag.add_document(clean_name, text)
    return {
        "ok": True,
        "name": clean_name,
        "kind": "pdf",
        "chars": len(text),
    }


async def _clear_all_documents() -> None:
    await agentop_rag.clear()


async def _list_documents() -> list[dict[str, Any]]:
    try:
        hits = await agentop_rag.search("*", k=50)
    except Exception:
        hits = []
    docs: dict[str, dict[str, Any]] = {}
    for hit in hits:
        source = (hit.get("source") or "Uploaded document").strip()
        text = hit.get("text") or ""
        if source not in docs:
            docs[source] = {"name": source, "kind": source.split(".")[-1].lower() if "." in source else "document", "chars": 0}
        docs[source]["chars"] += len(text)
    return sorted(docs.values(), key=lambda x: x["name"].lower())


async def process_user_query(query: str) -> str:
    clean_query = (query or "").strip()
    if not clean_query:
        return "Please ask a question about the uploaded employee handbook or policy documents."

    hits = await agentop_rag.search(clean_query, k=TOP_K)
    if not hits:
        return (
            "I could not find relevant content in the uploaded documents.\n\n"
            "Not covered in the uploaded handbook set. Please upload the relevant handbook, benefits guide, or policy document if you want me to answer."
        )

    filtered_hits = []
    for hit in hits:
        text = (hit.get("text") or "").strip()
        source = (hit.get("source") or "Uploaded document").strip()
        score = hit.get("score", 0)
        if text:
            filtered_hits.append({"source": source, "text": _truncate_text(text), "score": score})

    if not filtered_hits:
        return (
            "I found documents, but no readable policy text for this question.\n\n"
            "Not covered in the uploaded handbook set."
        )

    context_blocks = []
    sources = []
    for i, hit in enumerate(filtered_hits, start=1):
        sources.append(hit["source"])
        context_blocks.append(
            f"Source {i}: {hit['source']}\n"
            f"Relevance: {hit['score']}\n"
            f"Excerpt:\n{hit['text']}"
        )

    grounded_prompt = (
        "Answer the employee onboarding question using ONLY the provided document excerpts.\n\n"
        "Rules:\n"
        "- Do not use outside knowledge.\n"
        "- If the answer is not clearly stated in the excerpts, say: Not covered in the uploaded documents.\n"
        "- Do not guess or infer company policy details.\n"
        "- Cite the source document names in a final 'Sources:' line.\n"
        "- If excerpts conflict, say that the documents appear inconsistent and cite both sources.\n"
        "- Keep the answer concise and practical for a new hire.\n\n"
        f"Question:\n{clean_query}\n\n"
        f"Document excerpts:\n\n{'\n\n'.join(context_blocks)}"
    )

    answer = await process_user_query_wllama(grounded_prompt, TEMPLATE_SYSTEM_PROMPT)
    answer = (answer or "").strip()

    if not answer:
        return "Not covered in the uploaded documents.\n\nSources: None"

    unique_sources = []
    for src in sources:
        if src not in unique_sources:
            unique_sources.append(src)
    sources_line = "Sources: " + ", ".join(unique_sources)

    if "Sources:" not in answer:
        answer = answer + "\n\n" + sources_line

    return answer