{"slug": "grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without", "title": "Grounded AI: making an LLM draft a project charter from real data — without hallucinating", "summary": "A developer built a grounded charter generator for the Django-based project-management tool VanillaPM that drafts PMBOK-aligned project charters using only the user's actual input documents, such as the business case, benefits management plan, and agreements. The system caps each input document at 8,000 characters and the total grounding context at 40,000 characters, and its system prompt instructs the model to emit a short placeholder sentence naming missing inputs rather than fabricate content. The developer says giving the model an acceptable way to say it lacks information is the difference between a demo and a tool.", "body_md": "\"Add AI\" is the easiest line item on any 2026 roadmap and the easiest way to ship something worse than nothing. Wire an LLM to a \"Generate charter\" button, feed it the project name, and it will happily produce a beautifully-worded project charter full of objectives, success criteria and stakeholders that **do not exist**. In a governed project-management tool, a confident invention is worse than a blank field — someone might *believe* it.\n\nI wanted the generate button anyway, because there's a real version of it. Here's how I built a charter generator that drafts genuinely useful prose from the user's *actual* data, and refuses to make things up — in Django, bring-your-own-key, no framework magic.\n\n**Not autopopulate.** I already had a deterministic autopopulate: copy the sponsor from the project record, the dates from the schedule, and so on. That's great for *fields*, but a charter is mostly *prose* — a scope statement, an objectives section, a rationale — synthesised from the pre-project inputs (the business case, the benefits plan, the agreements). You can't `str()` your way to a scope statement. Autopopulate structurally can't do it.\n\n**Not a free-form LLM.** The other extreme — \"here's the project name, write me a charter\" — is exactly the hallucination machine. The model has no idea what your business case says, so it invents a plausible one.\n\nThe useful thing lives in between: **grounded synthesis.** Read the real input documents, and ask the model to *compose* — using only what's actually there.\n\nThe whole trick is that the model's only source of truth is text *you* supply from the user's own documents — never its training data.\n\nIn VanillaPM every document type declares its **input documents** (its ITTO prerequisites — the things that must exist before it). A charter's inputs are the business case, the benefits management plan, the agreements. So \"grounding\" is concrete: go read *those* documents.\n\n```\nDOC_CHAR_CAP     = 8_000    # per input document\nTOTAL_CONTEXT_CAP = 40_000  # the whole grounding context\n\ndef input_documents(project, doc_type):\n    \"\"\"(type, title, text) for each declared prerequisite that exists and has\n    readable text — each capped so one long doc can't dominate the context.\"\"\"\n    out = []\n    for prereq in prerequisite_types(project, doc_type):   # from the dependency graph\n        doc = latest_of_type(project, prereq)\n        text = document_text(doc)          # walk the stored rich-text → plain text\n        if text:\n            out.append((prereq, doc.title, text[:DOC_CHAR_CAP]))\n    return out\n```\n\nTwo boring-but-important details:\n\n`document_text()` walks the document's stored rich-text tree and pulls the prose the human actually wrote — not a JSON dump of every model.\nThe grounding context is then just: the deterministic structured facts (reused from autopopulate) **+** these input-document excerpts, under a big honest header.\n\nThis is the part people skip. The model will fabricate *unless you make refusing the easier path.* Three rules, and the second one is the whole ballgame:\n\n```\nSYSTEM_PROMPT = (\n    \"You draft sections of a PMBOK-aligned project document.\\n\"\n    \"1. Ground every section ONLY in the provided project data and input \"\n    \"   documents below. They are your only source of truth.\\n\"\n    \"2. If the data a section needs is missing, write ONE short professional \"\n    \"   sentence stating what input is still needed — do NOT fabricate.\\n\"\n    \"3. Return JSON only: \"\n    '{\"sections\":[{\"key\":\"<section_key>\",\"body\":\"<prose>\"}]}. '\n    \"Use only the section keys given. No markdown headings, no preamble.\"\n)\n```\n\nRule 2 is the difference between a demo and a tool. Giving the model an **acceptable way to say \"I don't have this\"** — a short \"needs input: the benefits plan doesn't state a target ROI\" placeholder — means it takes that exit instead of inventing a number. You're not just *asking* it not to hallucinate; you're handing it a better move than hallucinating.\n\nThe user turn wires the sections to draft onto the grounding context:\n\n```\nUSER_PROMPT = (\n    \"Draft these sections of the project's {doc_label}. Use each key exactly:\\n\\n\"\n    \"{targets}\\n\\n\"\n    \"=== PROJECT DATA AND INPUT DOCUMENTS (your only source of truth) ===\\n\"\n    \"{context}\"\n)\n```\n\n**Structured JSON out, fixed keys in.** The model returns `{\"sections\":[{\"key\", \"body\"}]}` and may only use keys I gave it. That does two jobs: parsing is trivial and deterministic, and the model can't wander off and invent a \"Section 12: Executive Bonus Plan.\" It fills the blanks I asked for, or it says it can't.\n\nA charter is a mix of *authored* prose sections and *live-data* sections (a stakeholder table, a risk snapshot) that are generated from real records. The AI must never touch the latter — those aren't opinions to draft, they're facts to render.\n\n``` python\ndef target_sections(content_json, overwrite=False):\n    for node in content_json.get(\"content\", []):\n        if section_has_datablock(node):        # a live register/table snapshot\n            continue                           # generated from real data — hands off\n        if section_has_text(node) and not overwrite:\n            continue                           # non-destructive: only fill empty sections\n        yield node[\"attrs\"][\"key\"], node[\"attrs\"][\"title\"]\n```\n\nSo the fill is **non-destructive** (it never overwrites what a human wrote unless they ask) and it **skips live-data sections entirely**. The model works on empty *prose* sections and nothing else. Bonus: because the section keys/titles come from the document itself, the exact same code drafts a communications plan or a risk-management plan — it generalised for free.\n\nAnd the output lands in **draft**, for a human to review and approve. The AI proposes; the PM decides. It's never a fact until a person signs off.\n\nNo forced AI markup, and no crash when AI isn't configured. The path resolves BYOK-first:\n\n``` python\ndef resolve_ai_path(user, org):\n    if byok_available(user, org):                     # user's own key\n        return \"byok\", user_key(user), MODEL_BYOK     #   → free through us\n    if managed_available(user, org):                  # metered credits\n        return \"managed\", server_key(), MODEL_MANAGED\n    return None, None, None                           # feature cleanly disabled\n```\n\nAnd the SDK import is **guarded** — a server without the `anthropic` package (or a user without a key) degrades to a disabled button, never an exception:\n\n``` python\ntry:\n    import anthropic\n    _AVAILABLE = True\nexcept ImportError:\n    anthropic, _AVAILABLE = None, False\n```\n\nNothing in the module holds a key; the caller passes the user's decrypted key in per request. (Load tests swap in an offline stub so they never hit the real API — cost and rate limits stay off the test path.)\n\nGrounding isn't a hallucination *cure*; it's a hallucination *budget*. What it reliably buys:\n\nWhat it doesn't do: it won't fix a vague business case (garbage in, grounded-garbage out), and a determined model can still mis-synthesise. So it's a *draft* generator with a human gate, not an autopilot — which is exactly what a governed document deserves.\n\nThe pattern generalises well beyond charters: **read the real inputs, cap and label them as the only source of truth, give the model an honest \"I don't know,\" constrain the output shape, and never let it touch data it should only render.** That's most of the distance between \"we added AI\" and AI you can actually put near real work.\n\n*I'm building a free, full-lifecycle PM platform in the open — this generator ships in it (BYOK). If the build-in-public engineering is your thing, the rest of the series is here.*", "url": "https://wpnews.pro/news/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without", "canonical_source": "https://dev.to/vanillapm/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without-hallucinating-4mgl", "published_at": "2026-09-18 05:47:06+00:00", "updated_at": "2026-09-18 06:23:01.305357+00:00", "lang": "en", "topics": ["large-language-models", "ai-tools", "generative-ai", "ai-products", "developer-tools"], "entities": ["VanillaPM", "Django", "PMBOK"], "alternates": {"html": "https://wpnews.pro/news/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without", "markdown": "https://wpnews.pro/news/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without.md", "text": "https://wpnews.pro/news/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without.txt", "jsonld": "https://wpnews.pro/news/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without.jsonld"}}