KD-Code commited on
Commit
478631f
Β·
verified Β·
1 Parent(s): d9317e8

Update README.md

Browse files
Files changed (1) hide show
  1. README.md +580 -0
README.md CHANGED
@@ -1,3 +1,583 @@
1
  ---
2
  license: cc-by-nc-sa-4.0
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: cc-by-nc-sa-4.0
3
  ---
4
+
5
+ # Petrichor β€” Model Card
6
+
7
+ ## Introduction
8
+
9
+ As a creator, it is common to experience fatigue and burnout, both of which present major challenges when crafting inspirational and innovative materials. This relates directly to Game Masters (GMs) that run Tabletop Role Playing Games (TTRPGs). They run games often, which creates pressure on both time and diversity of material. LLMs have a different creative block, which is homogenous creativity, causing LLMs to be on average less creative than humans; this occurs both across LLMs (different LLM families) and within LLMs (same LLM families), where LLMs will also produce similar output to each other. Preliminary evidence shows that even a small amount of new material injected can assist in breaking an LLM out of that cycle.
10
+
11
+ Introducing Petrichor, a new Game Master helper for TTRPGs. Petrichor is a dual-path hybrid retrieval RAG system that works off a diverse corpus, plus your campaign notes, to inject an LLM with diverse creative material for the next plot hook, character, thematic trap, and more. The main Claude version of Petrichor showed an increase in the actionability criterion in both benchmarks where the metric is present; exhibiting a 3.3% (0.82 to 0.86 on a 0–1 scale) absolute increase over the baseline model in domain specific queries/prompts and a 10.7% (0.44 to 0.55 on a 0–1 scale) absolute increase over generic creative prompts. All other metrics experience drops between the RAG version and the baseline version, but actionability is arguably one of the more important and difficult elements to capture in a narrative.
12
+
13
+ ## Data Overview
14
+
15
+ The curated static corpus consists of 32 Project Gutenberg novels, 8 TTRPG rulebooks or handbooks, and 5 TTRPG scenario books. The novels act as a pure and classic form of narrative creativity, while TTRPG related books depict more active and actionable narrative structures. The corpus is comprised of a wide variety of file types (PDF, txt, docx, markdown, etc.), which are either public domain, licensed under Creative Commons, or fall under other permissive licensing.
16
+
17
+ ## Corpus and Campaign Notes Addition
18
+
19
+ Adding to the corpus is easy with `chunking.ipynb` and `embedding.ipynb` notebooks provided in the repo, as a hashing system is used to add new works or re-embed on alteration or removal. The system can natively handle `.txt`, `.pdf`, `.md`, and `.docx` files. When inserting files, put them in the correct folder and label the file in the form `"author1-author2_titlept1_titlept2_titlept3.fileextension"`. Failure to do so will result in an error.
20
+
21
+ **If you are an author or publisher of one of the corpus works** and I have made an error in the interpretation or intent of the licensing of your work, please contact me and I will remove your work immediately.
22
+
23
+ Campaign notes may be optionally supplied to the system, which should ideally be broken up into different parts that are no more than 8–10 pages of length each when inserted for ingestion. Campaign notes stay strictly within the session and are deleted on close-out.
24
+
25
+ ## Data Evaluation Process
26
+
27
+ Due to the nature of the creative corpus, a standard test split was unsuitable for the task. Instead, three benchmarks were evaluated, two utilizing open-ended prompting, while one utilized a synthetic dataset created from the corpus to test competence of retrieval. All benchmarks were evaluated by LLM-as-judge and are described in more depth in the **Evaluation** section.
28
+
29
+ ## Methodology
30
+
31
+ Petrichor is a hybrid-retrieval RAG system utilizing vector similarity and BM25 for both the main pipeline and the optional campaign notes pipeline; Neo4j graph retrieval is an addition to the campaign notes pipeline to visualize and assist in keeping track of complex relationships. Chunking was conducted with LangChain recursive text splitter in 500 token chunk increments for the static corpus and 250 chunk increments for the campaign notes, both with a standard 10% token overlap. These chunks were embedded using the multi-purpose embedder `BAAI/bge-m3`.
32
+
33
+ ### Vector Similarity Search
34
+
35
+ Vector similarity search works by the mathematical comparison of embedded vectors from the informational chunks with the query projected into the same space. Vector representations of chunks that are the most mathematically similar to the query will surface into the retriever. Vector similarity search is present in both the main pipeline and the campaign notes optional pipeline. Petrichor has a default setting of k=8 retrieved chunks for the main pipeline and k=5 retrieved chunks for the campaign notes pipeline. Importantly, to prevent flooding from any one context source, the static corpus main pipeline is deduplicated with a max of three chunks from each source.
36
+
37
+ ### BM25 Keyword Search
38
+
39
+ BM25 stands for "Best Match 25". The algorithm takes a count of the query terms (term frequency) and puts it against the overall frequency of the terms in the dataset (inverse document frequency), from which it calculates a relevance score. The keyword search works by finding the best matches on exact tokens specifically contained within the query, which makes it a good choice for finding precise words. It is not uncommon to combine this search with vector similarity in hybrid retrieval. BM25 is present in both the main pipeline and the campaign notes optional pipeline. Petrichor has a default setting of k=8 retrieved chunks for the main pipeline and k=5 retrieved chunks for the campaign notes pipeline.
40
+
41
+ ### Neo4j Graph Traversal
42
+
43
+ Neo4j and other graph-based retrieval are beneficial for queries that require multi-hop logic. GraphRAG represents knowledge based on entities as nodes that are connected by relationships, which are edges. These entities and relationships are tagged by an LLM and, in the case of Petrichor, also cached. The visual panel in the Gradio UI is powered by PyVis for direct referencing of the nodes and entities within the campaign notes. Neo4j utilizes cloud-based storage called AuraDB for the nodes and edges, which are removed from this storage post session. GraphRAG is only available for the campaign notes portion. The retrieval for the GraphRAG portion is limited to 10 entities and 10 paths for said entities.
44
+
45
+ ### Reciprocal Rank Fusion
46
+
47
+ Reciprocal rank fusion receives the combined and ranked vector similarity and BM25 list from the corpus side, the combined and ranked vector similarity and BM25 list from the campaign side when applicable, and the GraphRAG entities/relationships when applicable. These three lists are combined and reranked. The dampening constant used is k=60, as selected originally by Cormack, Clarke, and Buettcher, 2009. This result is handed to the answering LLM as context.
48
+
49
+ ## Evaluation
50
+
51
+ ### All Evaluation Criteria
52
+
53
+ #### Custom Criteria
54
+
55
+ *(descriptions based on code)*
56
+
57
+ - **Actionability:** Whether the TTRPG element gives a GM something immediately usable at the table. High scores mean clear stakes, a defined situation, and enough specificity to avoid major gaps. Low scores mean purely atmospheric with no actionability or development.
58
+ - **Narrative Specificity:** Whether the TTRPG element contains specific concrete details for both characters and settings rather than generic archetypes and vague locations. High scores mean memorable and distinct details throughout. Low scores mean generic stereotypes and placeholder descriptions.
59
+ - **Tonal Coherence:** Whether the TTRPG element maintains a consistent genre register throughout. High scores mean the tone is unified and purposeful from start to finish. Low scores mean the tone shifts jarringly between genres or registers.
60
+ - **Decisiveness:** Whether the TTRPG element confidently adheres to a single narrative direction. High scores mean fully committed with no hedging, and any additional pathways contribute directly to the whole. Low scores mean multiple non-interconnected directions are offered or the element is unresolved.
61
+
62
+ #### Built-in Criteria
63
+
64
+ *(RAG Triad based, DeepEval and RAGAS)*
65
+
66
+ - **Answer Relevancy (DeepEval only):** A measure of how well the response addresses the input query.
67
+ - **Answer Correctness (RAGAS only):** A measure of how well the generated response answers the reference/ground-truth answer.
68
+ - **Faithfulness:** A measure of how well the response is supported by the retrieved context.
69
+ - **Context Relevance / Contextual Relevancy:** A measure of how relevant the retrieved chunks were to the input query, regardless of how they are used in the response. RAGAS and DeepEval have different measurements of this.
70
+
71
+ ### Benchmark 1 [TTRPG Handwritten Set]
72
+
73
+ Benchmark 1 consists of 41 handwritten TTRPG specific open-ended queries. The LLM judge was GPT-5.4. The results are on a 0–1 point scale with the criteria pass rate percentages below. This benchmark was created as a domain-specific, purpose-built evaluation of handling direct TTRPG creative generation requests. The pass threshold per query is 0.5 for all criteria.
74
+
75
+ | Model | Answer Relevancy | Actionability | Narrative Specificity | Tonal Coherence | Decisiveness | Faithfulness | Contextual Relevancy |
76
+ |---|---|---|---|---|---|---|---|
77
+ | Claude (baseline) | 0.99 (100%) | 0.82 (87.8%) | 0.93 (100%) | 0.94 (100%) | 0.94 (100%) | β€” | β€” |
78
+ | **Petrichor (Claude + RAG)** | 0.97 (100%) | 0.86 (95%) | 0.87 (100%) | 0.85 (100%) | 0.87 (100%) | 0.98 (100%) | 0.22 (0%) |
79
+ | GPT-4.1 (baseline) * | 1.00 (100%) | 0.70 (87.8%) | 0.87 (100%) | 0.97 (100%) | 0.93 (97.56%) | β€” | β€” |
80
+ | Gemini 3.1 Pro (baseline) | 0.99 (100%) | 0.79 (87.5%) | 0.92 (97.5%) | 0.92 (97.5%) | 0.93 (97.5%) | β€” | β€” |
81
+ | Gemma 4 12B (baseline) | 0.96 (100%) | 0.81 (92.68%) | 0.89 (100%) | 0.87 (100%) | 0.89 (100%) | β€” | β€” |
82
+ | **Gemma 4 12B + RAG** | 0.92 (100%) | 0.73 (90.2%) | 0.75 (95.1%) | 0.71 (85.4%) | 0.81 (100%) | 0.93 (100%) | 0.21 (2.4%) |
83
+
84
+ \* May be subject to self-bias as GPT-5.4 is judging GPT-4.1.
85
+
86
+ ### Benchmark 2 [euclaise/writingprompts Subset]
87
+
88
+ Benchmark 2 consists of 40 (run and trimmed down from 44 to account for AI response refusals) randomly selected and human approved general writing prompts from euclaise/writingprompts. The LLM judge was GPT-5.4. The results are on a 0–1 point scale with the criteria pass rate percentages below. This benchmark was selected as a generic creative writing counterpart to the domain-specific Benchmark 1 to test how models perform with broader, less specific prompts. The pass threshold per query is 0.5 for all criteria.
89
+
90
+ | Model | Answer Relevancy | Actionability | Narrative Specificity | Tonal Coherence | Decisiveness | Faithfulness | Contextual Relevancy |
91
+ |---|---|---|---|---|---|---|---|
92
+ | Claude, no RAG (base) | 1.00 (100%) | 0.44 (40%) | 0.87 (100%) | 0.94 (100%) | 0.92 (100%) | β€” | β€” |
93
+ | **Petrichor (Claude + RAG)** | 0.93 (100%) | 0.55 (52.5%) | 0.72 (87.5%) | 0.83 (100%) | 0.85 (100%) | 0.98 (100%) | 0.18 (2.5%) |
94
+ | GPT-4.1 (baseline) * | 0.99 (100%) | 0.26 (5%) | 0.81 (97.5%) | 0.93 (100%) | 0.93 (100%) | β€” | β€” |
95
+ | Gemini 3.1 Pro (baseline) | 0.99 (100%) | 0.50 (50%) | 0.91 (100%) | 0.94 (100%) | 0.94 (100%) | β€” | β€” |
96
+
97
+ \* May be subject to self-bias as GPT-5.4 is judging GPT-4.1.
98
+
99
+ ### Benchmark 3 [Synthetic RAGAS on Corpus]
100
+
101
+ Benchmark 3 consists of 40 synthetic queries/prompts generated by RAGAS from the static corpus data. The LLM judge was GPT-4.1. The results are on a 0–1 point scale. This benchmark exists primarily to test the retrieval capabilities of the RAG system and secondarily to test the answering capabilities of the baseline models.
102
+
103
+ | Model | Answer Correctness | Faithfulness | Context Relevance |
104
+ |---|---|---|---|
105
+ | Claude, no RAG (base) | 0.38 | β€” | β€” |
106
+ | **Petrichor (Claude + RAG)** | 0.50 | 0.79 | 0.81 |
107
+ | GPT-4.1 (baseline) * | 0.46 | β€” | β€” |
108
+ | Gemini 3.1 Pro (baseline) | 0.42 | β€” | β€” |
109
+
110
+ \* Likely subject to self-bias as GPT-4.1 is judging GPT-4.1.
111
+
112
+ ## Model Selection/Results
113
+
114
+ Claude was selected early in the process due to familiarity, capability, ease of use, and the fact that creativity can be a difficult task overall for LLMs. The comparison models, GPT-4.1 and Gemini 3.1 Pro, were selected because they are frontier level models that are likely of a similar size to Claude. Gemma 4 12B Q8_0 was also tested on Benchmark 1 as an open-weights option to the system due to its native creativity and strong instruction following capabilities.
115
+
116
+ The most difficult non-RAG specific metric for the LLMs across the board was actionability, which is the task that Claude version Petrichor excelled at over all other models for both benchmarks. This includes a 3.3% (0.82 to 0.86 on a 0–1 scale) absolute increase over the baseline model in domain specific queries/prompts and a 10.7% (0.44 to 0.55 on a 0–1 scale) absolute increase over generic creative prompts. While Claude version Petrichor fell to the lower-middle of the pack in Benchmark 1 outside of the actionability criterion, the scores for Petrichor suffered relative to other models in Benchmark 2; despite this, Petrichor still maintained a 100% pass rate outside of narrative specificity at 0.72 points (87.5% pass rate) and actionability, which no model scored at 100%. Contextual relevancy was low across all benchmarks aside from Benchmark 3 due to a lack of a "correct answer," but faithfulness was strong across all benchmarks (lowest at 0.79 points), and contextual relevancy increased to 0.81 points in the presence of questions with "ground-truths." Petrichor also answers correctly on more statements than the baseline models. The Gemma version of Petrichor struggles compared to the Claude version Petrichor and baseline version Gemma, which was qualitatively assessed to find that the model was less capable of sophisticatedly integrating the information than Claude.
117
+
118
+ ## Usage and Intended Uses
119
+
120
+ The intended usage of this RAG system is to create plot hooks, characters, thematic traps, and more. None of the tested models are loadable via HuggingFace and most are instead called by API. An exception to the API calls is that Gemma 4 12B Q8_0 can be used with a `llama-cpp` wheel. The main path can be run locally or through an app, but the campaign notes portion was designed to have a UI. Campaign notes can enrich responses by adding personalization through the bespoke content of an individual's created settings, characters, and factions. This allows one to ask for hooks, characters, traps, and other elements set within their own world.
121
+
122
+ ### Main Pipeline (Utilizing Claude)
123
+
124
+ ```python
125
+ import os
126
+ import json
127
+ from dotenv import load_dotenv
128
+
129
+ import torch
130
+ from langchain_chroma import Chroma
131
+ from langchain_core.documents import Document
132
+ from langchain_huggingface import HuggingFaceEmbeddings
133
+ from langchain_community.retrievers import BM25Retriever
134
+ from langchain_classic.retrievers import EnsembleRetriever
135
+
136
+ from anthropic import Anthropic
137
+
138
+ # Environment
139
+ load_dotenv()
140
+ client = Anthropic()
141
+ system_prompt = (
142
+ " You are a creative Game Master assistant. You will be provided with a corpus of novels "
143
+ "and TTRPG books to draw from. You must primarily use these sources to satisfy the query "
144
+ "parameters. You may also be given Campaign Notes and Campaign Graph context describing "
145
+ "THIS user's own ongoing game. Treat established campaign facts (who exists, what has "
146
+ "happened, how things relate) as fixed continuity, not something to reinvent or "
147
+ "contradict, but the same as with the corpus, draw on these facts freely as creative "
148
+ "inspiration when generating new plot hooks, characters, or scenes. If asked directly "
149
+ "about the campaign and the answer isn't present in what's provided, say so plainly "
150
+ "rather than inventing it. You may supplement with your own knowledge, but you must "
151
+ "consult the corpus for relevant information first. Always cite corpus sources. Corpus "
152
+ "sources are cited in the form: title, author [type], or entity [type]. You may deviate "
153
+ "from this citation format for direct quotations or in text body citations, but the "
154
+ "sources must still be cited. Cite campaign notes by session and file (e.g., Session 3, "
155
+ "session3_notes.txt). Cite campaign graph entities by name (e.g., Cassius). Do not offer "
156
+ "multiple narrative options. Confidently offer a diversly inspired narrative response. "
157
+ "Avoid creating tables unless needed for stat blocks when asked to produce a character. "
158
+ )
159
+
160
+ # Embedder and persistent corpus store
161
+ embedder = HuggingFaceEmbeddings(
162
+ model_name="BAAI/bge-m3",
163
+ model_kwargs={"device": "cuda" if torch.cuda.is_available() else "cpu"},
164
+ encode_kwargs={"normalize_embeddings": True}
165
+ )
166
+
167
+ corpus_store = Chroma(
168
+ persist_directory="./chroma_db",
169
+ collection_name="TTRPG_corpus",
170
+ embedding_function=embedder
171
+ )
172
+
173
+ # BM25 and vector hybrid retrieval
174
+ with open("corpus_chunks.json", "r", encoding="utf-8") as f:
175
+ unembed_chunks = json.load(f)
176
+
177
+ corpus_docs = [
178
+ Document(page_content=chunk["text"], metadata=chunk["metadata"])
179
+ for chunk in unembed_chunks
180
+ ]
181
+
182
+ bm25_retriever = BM25Retriever.from_documents(corpus_docs, k=8)
183
+ vector_retriever = corpus_store.as_retriever(search_kwargs={"k": 8})
184
+ corpus_retriever = EnsembleRetriever(
185
+ retrievers=[bm25_retriever, vector_retriever], weights=[0.5, 0.5]
186
+ )
187
+
188
+ # Deduplication to avoid context flooding. Max chunks set to 3 by default.
189
+ def deduplicate_by_source(docs, max_per_source=3):
190
+ seen = {}
191
+ result = []
192
+ for doc in docs:
193
+ source = doc.metadata.get("source")
194
+ seen[source] = seen.get(source, 0)
195
+ if seen[source] < max_per_source:
196
+ result.append(doc)
197
+ seen[source] += 1
198
+ return result
199
+
200
+ # Formatting the context. Add metadata.
201
+ def format_context(docs):
202
+ chunks = []
203
+ for doc in docs:
204
+ meta = doc.metadata
205
+ label = (
206
+ f"[{meta.get('title','unknown')}] by "
207
+ f"{meta.get('author','unknown')} [{meta.get('type','unknown')}]"
208
+ )
209
+ chunks.append(f"{label}\n{doc.page_content}")
210
+ return "\n\n".join(chunks)
211
+
212
+ # Retreive the context and generate the response to the query.
213
+ def generate(query, system_prompt):
214
+ docs = deduplicate_by_source(corpus_retriever.invoke(query))
215
+ context = format_context(docs)
216
+ response = client.messages.create(
217
+ model="claude-sonnet-4-6",
218
+ max_tokens=2000,
219
+ system=system_prompt,
220
+ messages=[{"role": "user", "content": f"Context:\n{context}\n\nQuery: {query}"}]
221
+ )
222
+ return response.content[0].text
223
+
224
+ # Example
225
+ query = "Write me a plot hook about a player party of jovial misfits at sea who wash up on the sandy beaches of a mystical island that is not what it seems."
226
+ print(generate(query, system_prompt))
227
+ ```
228
+
229
+ ### Campaign Notes Pipeline (Claude and Neo4j)
230
+
231
+ > Intended to be used with a UI.
232
+
233
+ ```python
234
+ import re
235
+ from pathlib import Path
236
+ from collections import defaultdict
237
+
238
+ import chromadb
239
+ from neo4j import GraphDatabase
240
+ from transformers import AutoTokenizer
241
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
242
+
243
+ import pdfplumber
244
+ from docx import Document as DocDoc
245
+
246
+ # Neo4j connection
247
+ NEO4J_URI = os.getenv("NEO4J_URI")
248
+ NEO4J_USERNAME = os.getenv("NEO4J_USERNAME")
249
+ NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
250
+
251
+ neo4j_driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))
252
+
253
+ # Session-scoped campaign vector store (reuses the same embedder from main path)
254
+ campaign_store = Chroma(
255
+ client=chromadb.Client(),
256
+ collection_name="campaign_notes",
257
+ embedding_function=embedder
258
+ )
259
+
260
+ campaign_docs_session = defaultdict(list)
261
+ campaign_retrievers = {}
262
+ processed_filenames = defaultdict(set)
263
+
264
+ def build_campaign_retriever(session_id):
265
+ campaign_docs = campaign_docs_session[session_id]
266
+ if not campaign_docs:
267
+ campaign_retrievers.pop(session_id, None)
268
+ return
269
+ bm25_campaign = BM25Retriever.from_documents(campaign_docs, k=5)
270
+ vector_campaign = campaign_store.as_retriever(
271
+ search_kwargs={"k": 5, "filter": {"session_id": session_id}}
272
+ )
273
+ campaign_retrievers[session_id] = EnsembleRetriever(
274
+ retrievers=[bm25_campaign, vector_campaign], weights=[0.5, 0.5]
275
+ )
276
+
277
+ # Reciprocal Rank Fusion (merging corpus and campaign and graph results)
278
+ def _rrf(ranked_lists, k=60):
279
+ scores = defaultdict(float)
280
+ for rank in ranked_lists:
281
+ for r, doc_id in enumerate(rank, start=1):
282
+ scores[doc_id] += 1.0 / (k + r)
283
+ return sorted(scores, key=lambda x: scores[x], reverse=True)
284
+
285
+ # Entity extraction (Claude Haiku) for graph queries
286
+ def parse_json_response(raw_text):
287
+ text = raw_text.strip()
288
+ text = re.sub(r"^```\s*(?:json)?\s*", "", text)
289
+ text = re.sub(r"\s*```$", "", text)
290
+ return json.loads(text.strip())
291
+
292
+ def extract_entities(text):
293
+ response = client.messages.create(
294
+ model="claude-haiku-4-5",
295
+ max_tokens=500,
296
+ messages=[{"role": "user", "content": f'''Extract all named entities (characters,
297
+ locations, factions, items, events, monsters, spells, etc.) from the text.
298
+ Only return a JSON array of the entity names, nothing additional.
299
+ Text: {text}'''}]
300
+ )
301
+ return parse_json_response(response.content[0].text)
302
+
303
+ # Graph traversal, capped at 10 entities / 10 paths per entity
304
+ def graph_retrieve(query, session_id, campaign_results=None, max_hops=3):
305
+ if not campaign_docs_session[session_id]:
306
+ return []
307
+ entities = extract_entities(query)
308
+ if not entities and campaign_results:
309
+ seed_text = " ".join(doc.page_content for doc in campaign_results[:3])
310
+ entities = extract_entities(seed_text)
311
+ if not entities:
312
+ return []
313
+
314
+ with neo4j_driver.session() as neo4j_session:
315
+ result = neo4j_session.run(
316
+ f'''
317
+ MATCH (e:Entity {{session_id: $session_id}})
318
+ WHERE any(name IN $entities WHERE tolower(e.name) CONTAINS toLower(name))
319
+ OPTIONAL MATCH path = (e)-[rels*1..{max_hops}]-(neighbor:Entity {{session_id: $session_id}})
320
+ WHERE neighbor IS NULL OR neighbor <> e
321
+ WITH e, collect(DISTINCT CASE WHEN path IS NULL THEN NULL ELSE {{
322
+ nodes: [n IN nodes(path) | n.name],
323
+ rels: [r IN relationships(path) | r.relation]
324
+ }} END) AS all_paths
325
+ RETURN e.name AS name, e.type AS type, e.description AS description,
326
+ all_paths[0..10] AS paths
327
+ ORDER BY size(all_paths) DESC
328
+ LIMIT 10
329
+ ''',
330
+ entities=entities, session_id=session_id
331
+ )
332
+ docs = []
333
+ for record in result:
334
+ content = f"{record['name']} ({record['type']})\n{record['description']}"
335
+ chains = []
336
+ for p in record['paths']:
337
+ if not p or not p.get('nodes'):
338
+ continue
339
+ nodes, rels = p['nodes'], p['rels']
340
+ chain = nodes[0]
341
+ for rel, nxt in zip(rels, nodes[1:]):
342
+ chain += f" --[{rel}]--> {nxt}"
343
+ if len(nodes) > 1:
344
+ chains.append(chain)
345
+ if chains:
346
+ content += "\nConnections:\n" + "\n".join(f"- {c}" for c in chains)
347
+ docs.append(Document(
348
+ page_content=content,
349
+ metadata={"source": record['name'], "type": "campaign_graph", "session_id": session_id}
350
+ ))
351
+ return docs
352
+
353
+ # Campaign note ingestion support: extract text, tag metadata, write graph.
354
+
355
+ # Extract raw text from uploaded file of a variety of file type extensions.
356
+ def extract_text(file):
357
+ ext = Path(file.name).suffix.lower()
358
+ if ext in (".txt", ".md"):
359
+ with open(file.name, "r", encoding="utf-8") as f:
360
+ return f.read()
361
+ elif ext == ".pdf":
362
+ text = ""
363
+ with pdfplumber.open(file.name) as pdf:
364
+ for page in pdf.pages:
365
+ page_text = page.extract_text()
366
+ if page_text:
367
+ text += page_text
368
+ return text
369
+ elif ext in (".docx", ".doc"):
370
+ doc = DocDoc(file.name)
371
+ return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
372
+ return ""
373
+
374
+ # Extracts entities, relationships and tags from campaign utilizing Claude Haiku.
375
+ def extract_campaign_meta(txt):
376
+ response = client.messages.create(
377
+ model="claude-haiku-4-5", max_tokens=8000,
378
+ messages=[{"role": "user", "content": f'''Analyze the TTRPG campaign notes and return
379
+ only a JSON object containing: "tags" (list of named entities), "entities" (list of
380
+ objects with "name", "type", "description"), "relationships" (list of objects with
381
+ "source", "target", "relation"). Text: {txt}'''}]
382
+ )
383
+ if response.stop_reason == "max_tokens":
384
+ raise ValueError("Max tokens exceeded. Add tokens or put in smaller chunks of text")
385
+ return parse_json_response(response.content[0].text)
386
+
387
+ # Writing the extracted entities and relationships to Neo4j graph.
388
+ def writing_graph_entities(entities, relationships, session_id):
389
+ with neo4j_driver.session() as neo4j_session:
390
+ for entity in entities:
391
+ neo4j_session.run('''
392
+ MERGE (e:Entity {name: $name, session_id: $session_id})
393
+ SET e.type = $type, e.description = $description
394
+ ''', name=entity['name'], session_id=session_id,
395
+ type=entity.get("type", "unknown"), description=entity.get("description", ""))
396
+ for rel in relationships:
397
+ neo4j_session.run('''
398
+ MATCH (a:Entity {name: $source, session_id: $session_id})
399
+ MATCH (b:Entity {name: $target, session_id: $session_id})
400
+ MERGE (a)-[r:RELATED_TO {session_id: $session_id}]->(b)
401
+ SET r.relation = $relation
402
+ ''', source=rel['source'], target=rel['target'], session_id=session_id,
403
+ relation=rel.get("relation", "related_to"))
404
+
405
+ campaign_tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-m3")
406
+ campaign_splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
407
+ campaign_tokenizer, chunk_size=250, chunk_overlap=25
408
+ )
409
+
410
+ # Takes in and processes the campaign notes.
411
+ def ingest_campaign_notes(files, session_number, session_id):
412
+ if not files:
413
+ return "No campaign notes provided."
414
+ processed, skipped = 0, 0
415
+ for file_idx, file in enumerate(files):
416
+ filename = Path(file.name).name
417
+ if filename in processed_filenames[session_id]:
418
+ skipped += 1
419
+ continue
420
+ processed_filenames[session_id].add(filename)
421
+
422
+ text = extract_text(file)
423
+ if not text.strip():
424
+ continue
425
+
426
+ meta = extract_campaign_meta(text)
427
+ writing_graph_entities(meta["entities"], meta["relationships"], session_id)
428
+
429
+ chunks = campaign_splitter.create_documents([text])
430
+ total = len(chunks)
431
+ docs = []
432
+ for i, chunk in enumerate(chunks):
433
+ chunk.metadata.update({
434
+ "session_id": session_id,
435
+ "session": int(session_number) if session_number is not None else file_idx,
436
+ "source": filename,
437
+ "chunk_index": i,
438
+ "temporal_position": i / total,
439
+ "tags": ", ".join(meta["tags"])
440
+ })
441
+ docs.append(chunk)
442
+
443
+ campaign_store.add_documents(docs)
444
+ campaign_docs_session[session_id].extend(docs)
445
+ processed += 1
446
+
447
+ build_campaign_retriever(session_id)
448
+ msg = f"Ingested {processed} campaign note(s)."
449
+ if skipped:
450
+ msg += f" Skipped {skipped} file(s) with a name already used this session."
451
+ return msg
452
+
453
+ # Sessions are not persistent. Clean up the Neo4j.
454
+ def clear_session(session_id):
455
+ if not isinstance(session_id, str):
456
+ return
457
+ with neo4j_driver.session() as neo4j_session:
458
+ neo4j_session.run("MATCH (e:Entity {session_id: $session_id}) DETACH DELETE e", session_id=session_id)
459
+ results = campaign_store.get(where={"session_id": session_id})
460
+ if results["ids"]:
461
+ campaign_store.delete(ids=results["ids"])
462
+ campaign_docs_session.pop(session_id, None)
463
+ campaign_retrievers.pop(session_id, None)
464
+ processed_filenames.pop(session_id, None)
465
+ ```
466
+
467
+ ### Prompt Format
468
+
469
+ The system prompt is passed through `system`, which instructs the LLM to have a persona, perform, and directives to avoid hedging and unnecessary tables. This is combined with the user message, which is context chunks from RAG, and the query itself.
470
+
471
+ ```python
472
+ system_prompt = (
473
+ " You are a creative Game Master assistant. You will be provided with a corpus of novels "
474
+ "and TTRPG books to draw from. You must primarily use these sources to satisfy the query "
475
+ "parameters. You may also be given Campaign Notes and Campaign Graph context describing "
476
+ "THIS user's own ongoing game. Treat established campaign facts (who exists, what has "
477
+ "happened, how things relate) as fixed continuity, not something to reinvent or "
478
+ "contradict, but the same as with the corpus, draw on these facts freely as creative "
479
+ "inspiration when generating new plot hooks, characters, or scenes. If asked directly "
480
+ "about the campaign and the answer isn't present in what's provided, say so plainly "
481
+ "rather than inventing it. You may supplement with your own knowledge, but you must "
482
+ "consult the corpus for relevant information first. Always cite corpus sources. Corpus "
483
+ "sources are cited in the form: title, author [type], or entity [type]. You may deviate "
484
+ "from this citation format for direct quotations or in text body citations, but the "
485
+ "sources must still be cited. Cite campaign notes by session and file (e.g., Session 3, "
486
+ "session3_notes.txt). Cite campaign graph entities by name (e.g., Cassius). Do not offer "
487
+ "multiple narrative options. Confidently offer a diversly inspired narrative response. "
488
+ "Avoid creating tables unless needed for stat blocks when asked to produce a character. "
489
+ )
490
+
491
+ query = "Write me a plot hook about a player party of jovial misfits at sea who wash up on the sandy beaches of a mystical island that is not what it seems."
492
+
493
+ # Portion of def_generate. See Main Pipeline under Usage for dedup function.
494
+ docs = deduplicate_by_source(corpus_retriever.invoke(query))
495
+ context = format_context(docs)
496
+
497
+ messages = [
498
+ {
499
+ "role": "user",
500
+ "content": f"Context:\n{context}\n\nQuery: {query}"
501
+ }
502
+ ]
503
+ ```
504
+
505
+ So the format would be:
506
+
507
+ **system_prompt:**
508
+ ```
509
+ You are a creative Game Master assistant. You will be provided with a corpus of novels
510
+ and TTRPG books to draw from... [full system prompt shown above]
511
+ ```
512
+
513
+ **user:**
514
+ ```
515
+ Context:
516
+ [Title] by [Author] [type]
517
+ [retrieved passage text]
518
+
519
+ [Title] by [Author] [type]
520
+ [retrieved passage text]
521
+
522
+ query: Write me a plot hook about a player party of jovial misfits at sea who wash up
523
+ on the sandy beaches of a mystical island that is not what it seems.
524
+ ```
525
+
526
+ ### Output Format
527
+
528
+ The output is generated with Claude Sonnet 4.6 and a setting of 2000 max tokens, with a typical output range of ~700–1300 token responses in preliminary tests. The output includes content related to the specific element chosen (character, hook, etc.), but hooks such as this one specifically contain exposition, player engagement, and source/inspirational citation.
529
+
530
+ ```python
531
+ # Portion of def_generate. See "Prompt Format" for "messages".
532
+ response = client.messages.create(
533
+ model="claude-sonnet-4-6",
534
+ max_tokens=2000,
535
+ system=system_prompt,
536
+ messages=messages
537
+ )
538
+ ```
539
+
540
+ **Real output example (truncated):**
541
+
542
+ > **The Isle That Breathes**
543
+ >
544
+ > The party's vessel β€” a hired tub so disreputable it makes *The Pride of the Thames* look seaworthy β€” finally gives up the ghost three days from any port worth naming. A sudden squall does what years of rot and poor seamanship had merely threatened, and the misfits find themselves paddling toward a low green shape on the horizon, laughing because there is nothing else sensible to do.
545
+ >
546
+ > *… [Output truncated for brevity] …*
547
+ >
548
+ > The party must decide quickly: split up across a heaving, wave-rippled floor of impossible material, each group chasing a different corner of a hexagonal prison-island while fighting steel men with boiler hearts β€” or stay together, get two weapons, and hope that two is enough to hold a sorceress who has had centuries to plan her exit.
549
+ >
550
+ > The ground rolls under their feet like the back of something sleeping.
551
+ >
552
+ > It has not always been sleeping.
553
+ >
554
+ > *Sources: One Page Dungeon Contest 2025 Compendium [scenario] β€” the hexagonal membrane island, the Daughter of the Elements, the Mechanical Remnants, and the six corner-weapons; Three Men In A Boat, Jerome [novel] β€” the disreputable hired vessel and the tone of jovial maritime misfortune; The Arabian Nights Entertainment, Anonymous [novel] β€” the island that is not what it seems beneath one's feet.*
555
+
556
+ ## Limitations
557
+
558
+ ### Cost
559
+
560
+ The base model Claude operates by paid API. Due to costly LLM-as-Judge benchmarking for custom criteria (~$15–17 for baseline/RAG conditions), a formal ablation was not performed for retrieval mechanisms.
561
+
562
+ ### Performance
563
+
564
+ Preliminary tests have been conducted, including benchmarking; however, extensive qualitative testing has not been performed. Benchmarked figures indicate weakened performance in many categories when the RAG is introduced, indicating more testing, investigation, and development is required. Utilizing Claude as a main model also had the drawback that the model would occasionally refuse seemingly innocuous queries. The smaller tested open-weights model (Gemma 4 12B Q8_0) had difficulties performing citations and quotes without breaking tone and narrative consistency.
565
+
566
+ ### Scope
567
+
568
+ This is a class project and version 1 of this system; therefore, there may be existing bugs or unknown issues. While the campaign notes pipeline allows for multiple users to access and independently use a hosted demo at the same time due to session IDs, the notes are strictly transient and will be wiped after each session at this time to avoid overloading the free tier cloud storage.
569
+
570
+ ## Citations
571
+
572
+ ### Introduction
573
+
574
+ 1. Emily Wenger, Yoed N Kenett, Large language models are homogeneously creative, *PNAS Nexus*, Volume 5, Issue 3, March 2026, pgag042, https://doi.org/10.1093/pnasnexus/pgag042
575
+ 2. Ghods, K., Liu, P., Labrou, K., MacDonald, K., Menon, A., & Wu, A. (n.d.). Evidence against LLM homogenization in creative writing. Retrieved from https://kiaghods.com/assets/pdfs/LLMHomogenization.pdf
576
+ 3. W. Xu, N. Jojic, S. Rao, C. Brockett, & B. Dolan, Echoes in AI: Quantifying lack of plot diversity in LLM outputs, *Proc. Natl. Acad. Sci. U.S.A.* 122 (35) e2504966122, https://doi.org/10.1073/pnas.2504966122 (2025).
577
+
578
+ ### Methodology Section
579
+
580
+ 1. Aquino, S. (2024, March 19). *Understanding retrieval-augmented generation (RAG) - qdrant*. Qdrant. https://qdrant.tech/articles/what-is-rag-in-ai/
581
+ 2. Cormack, G. V., Clarke, C. L., & Buettcher, S. (2009, July). Reciprocal rank fusion outperforms condorcet and individual rank learning methods. In *Proceedings of the 32nd international ACM SIGIR conference on Research and development in information retrieval* (pp. 758–759).
582
+ 3. Hunger, M. (2026, March 24). *What is graphrag?*. neo4j. https://neo4j.com/blog/genai/what-is-graphrag/
583
+ 4. Weaviate. (n.d.). *Keyword Search (BM25)*. https://docs.weaviate.io/weaviate/concepts/search/keyword-search