vivekchakraverty commited on
Commit
ac3a10b
·
verified ·
1 Parent(s): b84026d

Add curated legal scraper tab

Browse files
README.md CHANGED
@@ -15,6 +15,7 @@ CPU-first Hugging Face Space for a GameMaster game design assistant. It provides
15
  - A Gradio UI at `/ui`
16
  - A plugin-friendly JSON endpoint at `/api/chat`
17
  - A local RAG ingestion pipeline driven by `sources.yaml`
 
18
  - A pluggable LLM layer with a CPU-safe fallback and optional local GGUF model support
19
 
20
  The project intentionally ships with no scraped corpus. Add only sources you own, public-domain sources, permissively licensed sources, or sources where you have explicit permission.
@@ -95,6 +96,29 @@ python -m gamemaster_copilot.ingest --embedding-backend sentence-transformers
95
 
96
  Index files are written to `data/index/`.
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  ## Local Run
99
 
100
  ```bash
 
15
  - A Gradio UI at `/ui`
16
  - A plugin-friendly JSON endpoint at `/api/chat`
17
  - A local RAG ingestion pipeline driven by `sources.yaml`
18
+ - A Legal Scraper tab for curated open game-design resources
19
  - A pluggable LLM layer with a CPU-safe fallback and optional local GGUF model support
20
 
21
  The project intentionally ships with no scraped corpus. Add only sources you own, public-domain sources, permissively licensed sources, or sources where you have explicit permission.
 
96
 
97
  Index files are written to `data/index/`.
98
 
99
+ ## Legal Scraper Tab
100
+
101
+ The UI includes a `Legal Scraper` tab that can scrape a curated allowlist and rebuild the RAG index. It does not crawl arbitrary sites.
102
+
103
+ Current catalog:
104
+
105
+ - Game Design Concepts course, `CC BY 3.0 US`
106
+ - Wikipedia game design topics, `CC BY-SA 4.0 / GFDL`
107
+ - MIT OCW CMS.608 Game Design, `CC BY-NC-SA`
108
+ - Fate Core SRD, `CC BY 3.0 Unported`
109
+ - Blades in the Dark SRD, `CC BY 3.0 Unported`
110
+ - D&D Beyond SRD, `CC BY 4.0`
111
+
112
+ Legal guardrails:
113
+
114
+ - Only allowlisted catalog sources are scraped.
115
+ - `robots.txt` is checked before web page fetches.
116
+ - Binary assets, PDFs, images, scripts, and stylesheets are skipped.
117
+ - Every chunk stores source URL, license, attribution, and tags.
118
+ - Noncommercial/share-alike sources are flagged in the catalog metadata.
119
+
120
+ On Hugging Face free CPU, index builds can be slow. Start with a low `Max documents per source`, then raise it when the Space has enough time to complete ingestion. Runtime-built indexes are container-local unless you configure persistent storage or commit generated `data/index/` artifacts.
121
+
122
  ## Local Run
123
 
124
  ```bash
app.py CHANGED
@@ -6,6 +6,7 @@ import inspect
6
 
7
  from fastapi import FastAPI
8
 
 
9
  from gamemaster_copilot.config import MODES, get_settings
10
  from gamemaster_copilot.schemas import ChatRequest, ChatResponse
11
  from gamemaster_copilot.service import CopilotService
@@ -53,6 +54,14 @@ def _build_gradio_ui():
53
  except Exception:
54
  return None
55
 
 
 
 
 
 
 
 
 
56
  def submit(message: str, history: list[dict], project_context: str, mode: str, retrieval_k: int):
57
  history = history or []
58
  if not message or not message.strip():
@@ -71,6 +80,43 @@ def _build_gradio_ui():
71
  ]
72
  return "", history
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  with gr.Blocks(title="GameMaster Design Copilot") as demo:
75
  gr.HTML(
76
  """
@@ -91,54 +137,89 @@ def _build_gradio_ui():
91
  </div>
92
  """
93
  )
94
- with gr.Row():
95
- with gr.Column(scale=3):
96
- chatbot_kwargs = {"label": "Design conversation", "height": 520}
97
- if "type" in inspect.signature(gr.Chatbot).parameters:
98
- chatbot_kwargs["type"] = "messages"
99
- chatbot = gr.Chatbot(**chatbot_kwargs)
100
- message = gr.Textbox(
101
- label="Design request",
102
- placeholder="Example: Critique this stamina system for dominant strategies...",
103
- lines=3,
104
- )
105
  with gr.Row():
106
- send = gr.Button("Send", variant="primary")
107
- clear = gr.Button("Clear")
108
- with gr.Column(scale=1):
109
- mode = gr.Dropdown(
110
- choices=list(MODES.keys()),
111
- value="brainstorm",
112
- label="Mode",
113
- )
114
- retrieval_k = gr.Slider(
115
- minimum=0,
116
- maximum=10,
117
- value=4,
118
- step=1,
119
- label="Retrieved chunks",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  )
121
- project_context = gr.Textbox(
122
- label="Project context",
123
- placeholder="Genre, target audience, platform, rules constraints, design goals...",
124
- lines=14,
125
  )
 
 
 
126
  gr.Markdown(
127
- "The default app can answer without an index, but grounded answers require approved sources "
128
- "and a built RAG index."
 
129
  )
130
-
131
- send.click(
132
- submit,
133
- inputs=[message, chatbot, project_context, mode, retrieval_k],
134
- outputs=[message, chatbot],
135
- )
136
- message.submit(
137
- submit,
138
- inputs=[message, chatbot, project_context, mode, retrieval_k],
139
- outputs=[message, chatbot],
140
- )
141
- clear.click(lambda: [], outputs=[chatbot])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  return demo
143
 
144
 
 
6
 
7
  from fastapi import FastAPI
8
 
9
+ from gamemaster_copilot.catalog import build_catalog_index, catalog_summary_markdown, get_catalog
10
  from gamemaster_copilot.config import MODES, get_settings
11
  from gamemaster_copilot.schemas import ChatRequest, ChatResponse
12
  from gamemaster_copilot.service import CopilotService
 
54
  except Exception:
55
  return None
56
 
57
+ catalog_entries = get_catalog()
58
+ catalog_choice_labels = [
59
+ f"{entry.id} | {entry.label} | {entry.license}" for entry in catalog_entries
60
+ ]
61
+ catalog_label_to_id = {
62
+ f"{entry.id} | {entry.label} | {entry.license}": entry.id for entry in catalog_entries
63
+ }
64
+
65
  def submit(message: str, history: list[dict], project_context: str, mode: str, retrieval_k: int):
66
  history = history or []
67
  if not message or not message.strip():
 
80
  ]
81
  return "", history
82
 
83
+ def refresh_index_status():
84
+ return service.health()
85
+
86
+ def run_legal_scraper(selected_labels: list[str], max_docs_per_source: int):
87
+ selected_labels = selected_labels or []
88
+ selected_ids = [catalog_label_to_id[label] for label in selected_labels if label in catalog_label_to_id]
89
+ if not selected_ids:
90
+ return "Select at least one catalog source before scraping.", service.health()
91
+
92
+ try:
93
+ manifest = build_catalog_index(
94
+ selected_ids=selected_ids,
95
+ index_dir=settings.index_dir,
96
+ embedding_backend=settings.embedding_backend,
97
+ embedding_model=settings.embedding_model,
98
+ embedding_dimensions=settings.embedding_dimensions,
99
+ max_docs_per_source=int(max_docs_per_source),
100
+ )
101
+ health = service.reload_index()
102
+ except Exception as exc:
103
+ return f"Scrape failed: `{exc}`", service.health()
104
+
105
+ warning_text = ""
106
+ if manifest.get("warnings"):
107
+ warning_text = "\n\nWarnings:\n" + "\n".join(f"- {warning}" for warning in manifest["warnings"][:20])
108
+ if len(manifest["warnings"]) > 20:
109
+ warning_text += f"\n- ... {len(manifest['warnings']) - 20} more warnings"
110
+
111
+ summary = (
112
+ f"Built legal catalog index with **{manifest['scraped_document_count']} documents** "
113
+ f"and **{manifest['chunk_count']} chunks** from **{len(selected_ids)} catalog sources**.\n\n"
114
+ f"Embedding backend: `{manifest['embedding_backend']}`\n\n"
115
+ f"The chat retrieval layer has been reloaded. Current index chunks: `{health['chunk_count']}`."
116
+ f"{warning_text}"
117
+ )
118
+ return summary, manifest
119
+
120
  with gr.Blocks(title="GameMaster Design Copilot") as demo:
121
  gr.HTML(
122
  """
 
137
  </div>
138
  """
139
  )
140
+ with gr.Tabs():
141
+ with gr.Tab("Copilot"):
 
 
 
 
 
 
 
 
 
142
  with gr.Row():
143
+ with gr.Column(scale=3):
144
+ chatbot_kwargs = {"label": "Design conversation", "height": 520}
145
+ if "type" in inspect.signature(gr.Chatbot).parameters:
146
+ chatbot_kwargs["type"] = "messages"
147
+ chatbot = gr.Chatbot(**chatbot_kwargs)
148
+ message = gr.Textbox(
149
+ label="Design request",
150
+ placeholder="Example: Critique this stamina system for dominant strategies...",
151
+ lines=3,
152
+ )
153
+ with gr.Row():
154
+ send = gr.Button("Send", variant="primary")
155
+ clear = gr.Button("Clear")
156
+ with gr.Column(scale=1):
157
+ mode = gr.Dropdown(
158
+ choices=list(MODES.keys()),
159
+ value="brainstorm",
160
+ label="Mode",
161
+ )
162
+ retrieval_k = gr.Slider(
163
+ minimum=0,
164
+ maximum=10,
165
+ value=4,
166
+ step=1,
167
+ label="Retrieved chunks",
168
+ )
169
+ project_context = gr.Textbox(
170
+ label="Project context",
171
+ placeholder="Genre, target audience, platform, rules constraints, design goals...",
172
+ lines=14,
173
+ )
174
+ gr.Markdown(
175
+ "Grounded answers require approved sources and a built RAG index. "
176
+ "Use the Legal Scraper tab to build one from the curated catalog."
177
+ )
178
+
179
+ send.click(
180
+ submit,
181
+ inputs=[message, chatbot, project_context, mode, retrieval_k],
182
+ outputs=[message, chatbot],
183
  )
184
+ message.submit(
185
+ submit,
186
+ inputs=[message, chatbot, project_context, mode, retrieval_k],
187
+ outputs=[message, chatbot],
188
  )
189
+ clear.click(lambda: [], outputs=[chatbot])
190
+
191
+ with gr.Tab("Legal Scraper"):
192
  gr.Markdown(
193
+ "This scraper is intentionally allowlist-only. It does not crawl arbitrary sites, "
194
+ "does not ingest unclear copyrighted material, skips binary assets, checks robots.txt, "
195
+ "and preserves license/attribution metadata in every chunk."
196
  )
197
+ gr.Markdown(catalog_summary_markdown())
198
+ source_picker = gr.CheckboxGroup(
199
+ choices=catalog_choice_labels,
200
+ value=catalog_choice_labels,
201
+ label="Catalog sources to scrape",
202
+ )
203
+ max_docs = gr.Slider(
204
+ minimum=1,
205
+ maximum=80,
206
+ value=20,
207
+ step=1,
208
+ label="Max documents per source",
209
+ info="Use a lower number for faster CPU Space runs; raise it for deeper crawling.",
210
+ )
211
+ with gr.Row():
212
+ scrape_button = gr.Button("Scrape Selected Sources And Rebuild Index", variant="primary")
213
+ status_button = gr.Button("Refresh Index Status")
214
+ scrape_status = gr.Markdown()
215
+ index_manifest = gr.JSON(label="Index manifest / status")
216
+
217
+ scrape_button.click(
218
+ run_legal_scraper,
219
+ inputs=[source_picker, max_docs],
220
+ outputs=[scrape_status, index_manifest],
221
+ )
222
+ status_button.click(refresh_index_status, outputs=[index_manifest])
223
  return demo
224
 
225
 
gamemaster_copilot/catalog.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Curated legal source catalog and scraper-backed index builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from dataclasses import asdict, dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from urllib.parse import quote, urljoin, urlparse, urlunparse
12
+ from urllib.robotparser import RobotFileParser
13
+
14
+ import numpy as np
15
+ import requests
16
+ from bs4 import BeautifulSoup
17
+
18
+ from .embeddings import create_embedding_backend
19
+ from .index_store import save_index
20
+ from .text import TextChunk, chunk_text, html_to_text, normalize_text
21
+
22
+
23
+ DEFAULT_USER_AGENT = "GameMasterCopilot/0.1 (+https://huggingface.co/spaces/vivekchakraverty/gamemaster-design-copilot)"
24
+ SKIP_EXTENSIONS = {
25
+ ".7z",
26
+ ".avi",
27
+ ".css",
28
+ ".gif",
29
+ ".gz",
30
+ ".ico",
31
+ ".jpeg",
32
+ ".jpg",
33
+ ".js",
34
+ ".json",
35
+ ".mp3",
36
+ ".mp4",
37
+ ".ogg",
38
+ ".pdf",
39
+ ".png",
40
+ ".svg",
41
+ ".tar",
42
+ ".webm",
43
+ ".zip",
44
+ }
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ScrapedDocument:
49
+ """One fetched document ready for chunking."""
50
+
51
+ source_id: str
52
+ title: str
53
+ text: str
54
+ url: str
55
+ license: str
56
+ attribution: str
57
+ tags: list[str]
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class CatalogEntry:
62
+ """One allowlisted source collection."""
63
+
64
+ id: str
65
+ label: str
66
+ description: str
67
+ license: str
68
+ permission: str
69
+ attribution: str
70
+ tags: list[str]
71
+ kind: str
72
+ source_url: str
73
+ start_urls: tuple[str, ...] = ()
74
+ allowed_prefixes: tuple[str, ...] = ()
75
+ api_url: str | None = None
76
+ pages: tuple[str, ...] = ()
77
+ notes: str = ""
78
+
79
+
80
+ WIKIPEDIA_GAME_DESIGN_PAGES = (
81
+ "Game design",
82
+ "Video game design",
83
+ "Game mechanics",
84
+ "Gameplay",
85
+ "Game balance",
86
+ "Level design",
87
+ "Playtesting",
88
+ "Emergent gameplay",
89
+ "Nonlinear gameplay",
90
+ "Game studies",
91
+ "Ludology",
92
+ "Flow (psychology)",
93
+ "Tutorial (video games)",
94
+ "Boss (video games)",
95
+ "Game artificial intelligence",
96
+ "Serious game",
97
+ )
98
+
99
+
100
+ CATALOG: tuple[CatalogEntry, ...] = (
101
+ CatalogEntry(
102
+ id="game_design_concepts",
103
+ label="Game Design Concepts course",
104
+ description="Ian Schreiber's free game design course posts.",
105
+ license="CC BY 3.0 US",
106
+ permission="permissive",
107
+ attribution="Game Design Concepts by Ian Schreiber",
108
+ tags=["game-design", "course", "mechanics", "iteration", "balance"],
109
+ kind="wordpress_rest",
110
+ source_url="https://gamedesignconcepts.wordpress.com/about/",
111
+ api_url="https://gamedesignconcepts.wordpress.com/wp-json/wp/v2/posts",
112
+ notes="The course about page states that course content may be shared and adapted with attribution.",
113
+ ),
114
+ CatalogEntry(
115
+ id="wikipedia_game_design",
116
+ label="Wikipedia game design topics",
117
+ description="Selected game design, mechanics, level design, and playtesting encyclopedia topics.",
118
+ license="CC BY-SA 4.0 / GFDL",
119
+ permission="sharealike_open",
120
+ attribution="Wikipedia contributors",
121
+ tags=["game-design", "mechanics", "level-design", "encyclopedia"],
122
+ kind="mediawiki_pages",
123
+ source_url="https://en.wikipedia.org/wiki/Wikipedia:Copyrights",
124
+ api_url="https://api.wikimedia.org/core/v1/wikipedia/en/page",
125
+ pages=WIKIPEDIA_GAME_DESIGN_PAGES,
126
+ notes="Text reuse requires attribution and share-alike compliance.",
127
+ ),
128
+ CatalogEntry(
129
+ id="mit_ocw_cms608_2014",
130
+ label="MIT OCW CMS.608 Game Design",
131
+ description="MIT OpenCourseWare Game Design course pages from Spring 2014.",
132
+ license="CC BY-NC-SA",
133
+ permission="noncommercial_open",
134
+ attribution="MIT OpenCourseWare, CMS.608 Game Design, Spring 2014",
135
+ tags=["game-design", "course", "non-digital-games", "assignments"],
136
+ kind="crawl_prefix",
137
+ source_url="https://ocw.mit.edu/courses/cms-608-game-design-spring-2014/",
138
+ start_urls=("https://ocw.mit.edu/courses/cms-608-game-design-spring-2014/",),
139
+ allowed_prefixes=("https://ocw.mit.edu/courses/cms-608-game-design-spring-2014/",),
140
+ notes="MIT OCW materials are open, but this catalog entry is noncommercial/share-alike.",
141
+ ),
142
+ CatalogEntry(
143
+ id="fate_core_srd",
144
+ label="Fate Core SRD",
145
+ description="Fate Core SRD pages for tabletop RPG system design examples.",
146
+ license="CC BY 3.0 Unported",
147
+ permission="permissive",
148
+ attribution="Fate Core System by Evil Hat Productions and Fate SRD contributors",
149
+ tags=["ttrpg", "srd", "narrative-design", "resolution-mechanics"],
150
+ kind="crawl_prefix",
151
+ source_url="https://fate-srd.com/official-licensing-fate",
152
+ start_urls=("https://fate-srd.com/fate-core/", "https://fate-srd.com/official-licensing-fate"),
153
+ allowed_prefixes=("https://fate-srd.com/fate-core/", "https://fate-srd.com/official-licensing-fate"),
154
+ notes="The official licensing page identifies Fate SRD as the endorsed SRD source.",
155
+ ),
156
+ CatalogEntry(
157
+ id="blades_srd",
158
+ label="Blades in the Dark SRD",
159
+ description="Forged in the Dark SRD pages for position/effect, clocks, crews, and action resolution.",
160
+ license="CC BY 3.0 Unported",
161
+ permission="permissive",
162
+ attribution="Blades in the Dark by One Seven Design, developed and authored by John Harper",
163
+ tags=["ttrpg", "srd", "fiction-first", "clocks", "gm-tools"],
164
+ kind="crawl_prefix",
165
+ source_url="https://bladesinthedark.com/licensing",
166
+ start_urls=("https://bladesinthedark.com/basics/", "https://bladesinthedark.com/licensing"),
167
+ allowed_prefixes=("https://bladesinthedark.com/",),
168
+ notes="Catalog notes warn users not to use non-SRD setting, NPC, artwork, or map material.",
169
+ ),
170
+ CatalogEntry(
171
+ id="dnd_srd",
172
+ label="D&D Beyond SRD",
173
+ description="Official D&D SRD page for rules/mechanics examples under Creative Commons.",
174
+ license="CC BY 4.0",
175
+ permission="permissive",
176
+ attribution="System Reference Document by Wizards of the Coast LLC",
177
+ tags=["ttrpg", "srd", "rules", "encounters", "mechanics"],
178
+ kind="single_pages",
179
+ source_url="https://www.dndbeyond.com/srd",
180
+ start_urls=("https://www.dndbeyond.com/srd",),
181
+ notes="Only SRD content is included; trademarks and non-SRD material remain outside scope.",
182
+ ),
183
+ )
184
+
185
+
186
+ def get_catalog() -> list[CatalogEntry]:
187
+ """Return the curated legal source catalog."""
188
+
189
+ return list(CATALOG)
190
+
191
+
192
+ def get_catalog_entry(entry_id: str) -> CatalogEntry:
193
+ for entry in CATALOG:
194
+ if entry.id == entry_id:
195
+ return entry
196
+ raise KeyError(f"unknown catalog source: {entry_id}")
197
+
198
+
199
+ def catalog_summary_markdown() -> str:
200
+ lines = [
201
+ "### Curated Legal Source Catalog",
202
+ "",
203
+ "Only these allowlisted sources can be scraped by the UI. Robots.txt is checked for web pages, "
204
+ "binary assets are skipped, and each chunk keeps license and attribution metadata.",
205
+ "",
206
+ ]
207
+ for entry in CATALOG:
208
+ lines.append(f"- **{entry.label}** (`{entry.id}`): {entry.license}. {entry.description}")
209
+ return "\n".join(lines)
210
+
211
+
212
+ def _safe_slug(value: str) -> str:
213
+ cleaned = "".join(char.lower() if char.isalnum() else "-" for char in value)
214
+ cleaned = "-".join(part for part in cleaned.split("-") if part)
215
+ return cleaned[:80] or hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
216
+
217
+
218
+ def _canonical_url(url: str) -> str:
219
+ parsed = urlparse(url)
220
+ return urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/") + "/", "", "", ""))
221
+
222
+
223
+ def _skip_url(url: str) -> bool:
224
+ path = urlparse(url).path.lower()
225
+ return any(path.endswith(extension) for extension in SKIP_EXTENSIONS)
226
+
227
+
228
+ def _robots_allowed(url: str, user_agent: str) -> bool:
229
+ parsed = urlparse(url)
230
+ robots_url = urljoin(f"{parsed.scheme}://{parsed.netloc}", "/robots.txt")
231
+ try:
232
+ response = requests.get(robots_url, headers={"User-Agent": user_agent}, timeout=10)
233
+ except Exception:
234
+ return False
235
+ if response.status_code == 404:
236
+ return True
237
+ if response.status_code >= 400:
238
+ return False
239
+ parser = RobotFileParser()
240
+ parser.set_url(robots_url)
241
+ parser.parse(response.text.splitlines())
242
+ return parser.can_fetch(user_agent, url)
243
+
244
+
245
+ def _fetch(url: str, user_agent: str, timeout_seconds: int) -> requests.Response:
246
+ if not _robots_allowed(url, user_agent):
247
+ raise PermissionError(f"robots.txt disallows fetching {url}")
248
+ response = requests.get(url, headers={"User-Agent": user_agent}, timeout=timeout_seconds)
249
+ response.raise_for_status()
250
+ return response
251
+
252
+
253
+ def _document_from_html(entry: CatalogEntry, url: str, html: str, fallback_title: str | None = None) -> ScrapedDocument:
254
+ soup = BeautifulSoup(html, "html.parser")
255
+ title_node = soup.find("h1") or soup.find("title")
256
+ title = normalize_text(title_node.get_text(" ")) if title_node else fallback_title or entry.label
257
+ text = html_to_text(html)
258
+ return ScrapedDocument(
259
+ source_id=f"{entry.id}.{_safe_slug(url)}",
260
+ title=title or entry.label,
261
+ text=text,
262
+ url=url,
263
+ license=entry.license,
264
+ attribution=entry.attribution,
265
+ tags=list(entry.tags),
266
+ )
267
+
268
+
269
+ def _extract_links(html: str, base_url: str, allowed_prefixes: tuple[str, ...]) -> list[str]:
270
+ soup = BeautifulSoup(html, "html.parser")
271
+ links: list[str] = []
272
+ for anchor in soup.find_all("a", href=True):
273
+ url = urljoin(base_url, anchor["href"])
274
+ parsed = urlparse(url)
275
+ if parsed.scheme not in {"http", "https"}:
276
+ continue
277
+ candidate = urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", ""))
278
+ if _skip_url(candidate):
279
+ continue
280
+ if any(candidate.startswith(prefix) for prefix in allowed_prefixes):
281
+ links.append(candidate)
282
+ return links
283
+
284
+
285
+ def _scrape_single_pages(entry: CatalogEntry, max_docs: int, user_agent: str, timeout_seconds: int) -> tuple[list[ScrapedDocument], list[str]]:
286
+ docs: list[ScrapedDocument] = []
287
+ warnings: list[str] = []
288
+ for url in entry.start_urls[:max_docs]:
289
+ try:
290
+ response = _fetch(url, user_agent, timeout_seconds)
291
+ docs.append(_document_from_html(entry, url, response.text))
292
+ except Exception as exc:
293
+ warnings.append(f"skipped {url}: {exc}")
294
+ return docs, warnings
295
+
296
+
297
+ def _scrape_crawl_prefix(entry: CatalogEntry, max_docs: int, user_agent: str, timeout_seconds: int) -> tuple[list[ScrapedDocument], list[str]]:
298
+ docs: list[ScrapedDocument] = []
299
+ warnings: list[str] = []
300
+ queue = list(entry.start_urls)
301
+ seen: set[str] = set()
302
+
303
+ while queue and len(docs) < max_docs:
304
+ url = queue.pop(0)
305
+ canonical = _canonical_url(url)
306
+ if canonical in seen or _skip_url(url):
307
+ continue
308
+ seen.add(canonical)
309
+ if not any(url.startswith(prefix) for prefix in entry.allowed_prefixes):
310
+ continue
311
+
312
+ try:
313
+ response = _fetch(url, user_agent, timeout_seconds)
314
+ content_type = response.headers.get("content-type", "").lower()
315
+ if "text/html" not in content_type:
316
+ warnings.append(f"skipped non-html URL {url}: {content_type or 'unknown content type'}")
317
+ continue
318
+ docs.append(_document_from_html(entry, url, response.text))
319
+ for link in _extract_links(response.text, url, entry.allowed_prefixes):
320
+ if _canonical_url(link) not in seen and link not in queue:
321
+ queue.append(link)
322
+ time.sleep(0.1)
323
+ except Exception as exc:
324
+ warnings.append(f"skipped {url}: {exc}")
325
+ return docs, warnings
326
+
327
+
328
+ def _scrape_wordpress_rest(entry: CatalogEntry, max_docs: int, user_agent: str, timeout_seconds: int) -> tuple[list[ScrapedDocument], list[str]]:
329
+ if not entry.api_url:
330
+ return [], [f"{entry.id} is missing api_url"]
331
+
332
+ docs: list[ScrapedDocument] = []
333
+ warnings: list[str] = []
334
+ page = 1
335
+ while len(docs) < max_docs:
336
+ url = f"{entry.api_url}?per_page=20&page={page}&_fields=link,title,content"
337
+ try:
338
+ response = _fetch(url, user_agent, timeout_seconds)
339
+ except requests.HTTPError as exc:
340
+ if exc.response is not None and exc.response.status_code == 400:
341
+ break
342
+ warnings.append(f"stopped WordPress fetch at page {page}: {exc}")
343
+ break
344
+ except Exception as exc:
345
+ warnings.append(f"stopped WordPress fetch at page {page}: {exc}")
346
+ break
347
+
348
+ posts = response.json()
349
+ if not posts:
350
+ break
351
+ for post in posts:
352
+ title = html_to_text(post.get("title", {}).get("rendered", "")) or entry.label
353
+ link = post.get("link") or entry.source_url
354
+ content = post.get("content", {}).get("rendered", "")
355
+ text = html_to_text(content)
356
+ if not text:
357
+ continue
358
+ docs.append(
359
+ ScrapedDocument(
360
+ source_id=f"{entry.id}.{_safe_slug(link)}",
361
+ title=title,
362
+ text=text,
363
+ url=link,
364
+ license=entry.license,
365
+ attribution=entry.attribution,
366
+ tags=list(entry.tags),
367
+ )
368
+ )
369
+ if len(docs) >= max_docs:
370
+ break
371
+ page += 1
372
+ return docs, warnings
373
+
374
+
375
+ def _scrape_mediawiki_pages(entry: CatalogEntry, max_docs: int, user_agent: str, timeout_seconds: int) -> tuple[list[ScrapedDocument], list[str]]:
376
+ if not entry.api_url:
377
+ return [], [f"{entry.id} is missing api_url"]
378
+
379
+ docs: list[ScrapedDocument] = []
380
+ warnings: list[str] = []
381
+ for title in entry.pages[:max_docs]:
382
+ encoded_title = quote(title.replace(" ", "_"), safe="")
383
+ fetch_url = f"{entry.api_url.rstrip('/')}/{encoded_title}/html"
384
+ try:
385
+ response = _fetch(fetch_url, user_agent, timeout_seconds)
386
+ text = html_to_text(response.text)
387
+ if not text:
388
+ warnings.append(f"empty Wikipedia page extract: {title}")
389
+ continue
390
+ full_url = f"https://en.wikipedia.org/wiki/{encoded_title}"
391
+ docs.append(
392
+ ScrapedDocument(
393
+ source_id=f"{entry.id}.{_safe_slug(title)}",
394
+ title=title,
395
+ text=text,
396
+ url=full_url,
397
+ license=entry.license,
398
+ attribution=entry.attribution,
399
+ tags=list(entry.tags),
400
+ )
401
+ )
402
+ time.sleep(0.1)
403
+ except Exception as exc:
404
+ warnings.append(f"skipped Wikipedia page {title}: {exc}")
405
+ return docs, warnings
406
+
407
+
408
+ def scrape_catalog_entry(
409
+ entry: CatalogEntry,
410
+ *,
411
+ max_docs: int = 30,
412
+ user_agent: str = DEFAULT_USER_AGENT,
413
+ timeout_seconds: int = 20,
414
+ ) -> tuple[list[ScrapedDocument], list[str]]:
415
+ """Scrape one curated catalog entry."""
416
+
417
+ bounded_max_docs = max(1, min(max_docs, 200))
418
+ if entry.kind == "single_pages":
419
+ return _scrape_single_pages(entry, bounded_max_docs, user_agent, timeout_seconds)
420
+ if entry.kind == "crawl_prefix":
421
+ return _scrape_crawl_prefix(entry, bounded_max_docs, user_agent, timeout_seconds)
422
+ if entry.kind == "wordpress_rest":
423
+ return _scrape_wordpress_rest(entry, bounded_max_docs, user_agent, timeout_seconds)
424
+ if entry.kind == "mediawiki_pages":
425
+ return _scrape_mediawiki_pages(entry, bounded_max_docs, user_agent, timeout_seconds)
426
+ raise ValueError(f"unsupported catalog scraper kind: {entry.kind}")
427
+
428
+
429
+ def build_catalog_index(
430
+ *,
431
+ selected_ids: list[str],
432
+ index_dir: str | Path,
433
+ embedding_backend: str,
434
+ embedding_model: str,
435
+ embedding_dimensions: int,
436
+ max_docs_per_source: int = 30,
437
+ chunk_words: int = 260,
438
+ overlap_words: int = 50,
439
+ ) -> dict:
440
+ """Scrape selected allowlisted catalog sources and persist a RAG index."""
441
+
442
+ if not selected_ids:
443
+ raise ValueError("select at least one catalog source")
444
+
445
+ entries = [get_catalog_entry(entry_id) for entry_id in selected_ids]
446
+ embedder = create_embedding_backend(
447
+ embedding_backend,
448
+ model_name=embedding_model,
449
+ dimensions=embedding_dimensions,
450
+ )
451
+
452
+ documents: list[ScrapedDocument] = []
453
+ chunks: list[TextChunk] = []
454
+ warnings: list[str] = []
455
+ seen_hashes: set[str] = set()
456
+
457
+ for entry in entries:
458
+ docs, entry_warnings = scrape_catalog_entry(entry, max_docs=max_docs_per_source)
459
+ warnings.extend(f"{entry.id}: {warning}" for warning in entry_warnings)
460
+ documents.extend(docs)
461
+ for doc in docs:
462
+ for chunk in chunk_text(
463
+ source_id=doc.source_id,
464
+ title=doc.title,
465
+ text=doc.text,
466
+ url=doc.url,
467
+ license=doc.license,
468
+ attribution=doc.attribution,
469
+ tags=doc.tags,
470
+ chunk_words=chunk_words,
471
+ overlap_words=overlap_words,
472
+ ):
473
+ digest = hashlib.sha256(chunk.text.encode("utf-8")).hexdigest()
474
+ if digest in seen_hashes:
475
+ continue
476
+ seen_hashes.add(digest)
477
+ chunks.append(chunk)
478
+
479
+ if chunks:
480
+ vectors = embedder.encode([chunk.text for chunk in chunks])
481
+ else:
482
+ vectors = np.zeros((0, embedder.dimensions), dtype=np.float32)
483
+
484
+ manifest = {
485
+ "created_at": datetime.now(timezone.utc).isoformat(),
486
+ "source_registry": "curated_legal_catalog",
487
+ "legal_policy": "Curated allowlist only; robots.txt checked for web pages; binary assets skipped; citations retain license and attribution metadata.",
488
+ "selected_catalog_source_ids": selected_ids,
489
+ "registered_source_count": len(entries),
490
+ "scraped_document_count": len(documents),
491
+ "ingested_source_count": len({doc.source_id for doc in documents}),
492
+ "chunk_count": len(chunks),
493
+ "embedding_backend": embedder.name,
494
+ "embedding_dimensions": int(vectors.shape[1]) if vectors.ndim == 2 else embedder.dimensions,
495
+ "catalog": [asdict(entry) for entry in entries],
496
+ "warnings": warnings,
497
+ }
498
+ save_index(index_dir, chunks=chunks, vectors=vectors, manifest=manifest)
499
+
500
+ manifest_path = Path(index_dir) / "catalog-manifest.json"
501
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
502
+ return manifest
gamemaster_copilot/service.py CHANGED
@@ -35,6 +35,13 @@ class CopilotService:
35
  "index_dir": str(self.settings.index_dir),
36
  }
37
 
 
 
 
 
 
 
 
38
  def chat(self, request: ChatRequest) -> ChatResponse:
39
  warnings: list[str] = []
40
  results: list[SearchResult] = []
@@ -70,4 +77,3 @@ class CopilotService:
70
  retrieved_chunks=[result.to_retrieved_chunk() for result in results],
71
  warnings=warnings,
72
  )
73
-
 
35
  "index_dir": str(self.settings.index_dir),
36
  }
37
 
38
+ def reload_index(self) -> dict:
39
+ """Reload the on-disk vector index after ingestion."""
40
+
41
+ self.index.loaded = False
42
+ self.index.load()
43
+ return self.health()
44
+
45
  def chat(self, request: ChatRequest) -> ChatResponse:
46
  warnings: list[str] = []
47
  results: list[SearchResult] = []
 
77
  retrieved_chunks=[result.to_retrieved_chunk() for result in results],
78
  warnings=warnings,
79
  )
 
gamemaster_copilot/sources.py CHANGED
@@ -9,7 +9,14 @@ import yaml
9
  from pydantic import BaseModel, Field, HttpUrl, model_validator
10
 
11
 
12
- Permission = Literal["public_domain", "permissive", "owned", "explicit_permission"]
 
 
 
 
 
 
 
13
 
14
 
15
  class CrawlPolicy(BaseModel):
 
9
  from pydantic import BaseModel, Field, HttpUrl, model_validator
10
 
11
 
12
+ Permission = Literal[
13
+ "public_domain",
14
+ "permissive",
15
+ "sharealike_open",
16
+ "noncommercial_open",
17
+ "owned",
18
+ "explicit_permission",
19
+ ]
20
 
21
 
22
  class CrawlPolicy(BaseModel):
tests/test_catalog.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import gamemaster_copilot.catalog as catalog
4
+ from gamemaster_copilot.catalog import ScrapedDocument, build_catalog_index, get_catalog
5
+
6
+
7
+ def test_catalog_entries_have_required_legal_metadata() -> None:
8
+ entries = get_catalog()
9
+
10
+ assert entries
11
+ for entry in entries:
12
+ assert entry.id
13
+ assert entry.license
14
+ assert entry.permission in {"permissive", "sharealike_open", "noncommercial_open"}
15
+ assert entry.attribution
16
+ assert entry.source_url.startswith("https://")
17
+
18
+
19
+ def test_build_catalog_index_with_mocked_scraper(tmp_path: Path, monkeypatch) -> None:
20
+ def fake_scrape(entry, *, max_docs=30, user_agent=catalog.DEFAULT_USER_AGENT, timeout_seconds=20):
21
+ return (
22
+ [
23
+ ScrapedDocument(
24
+ source_id=f"{entry.id}.sample",
25
+ title=f"{entry.label} Sample",
26
+ text="Level design teaches mechanics by sequencing pressure, safety, discovery, and feedback.",
27
+ url=entry.source_url,
28
+ license=entry.license,
29
+ attribution=entry.attribution,
30
+ tags=entry.tags,
31
+ )
32
+ ],
33
+ [],
34
+ )
35
+
36
+ monkeypatch.setattr(catalog, "scrape_catalog_entry", fake_scrape)
37
+
38
+ manifest = build_catalog_index(
39
+ selected_ids=["wikipedia_game_design"],
40
+ index_dir=tmp_path / "index",
41
+ embedding_backend="hash",
42
+ embedding_model="unused",
43
+ embedding_dimensions=384,
44
+ max_docs_per_source=2,
45
+ chunk_words=12,
46
+ overlap_words=2,
47
+ )
48
+
49
+ assert manifest["scraped_document_count"] == 1
50
+ assert manifest["chunk_count"] > 0
51
+ assert manifest["selected_catalog_source_ids"] == ["wikipedia_game_design"]
52
+ assert (tmp_path / "index" / "catalog-manifest.json").exists()
53
+