comparison mrjunejune/inference/public_knowledge.py @ 265:056790c4fb0d

add role-aware Epi assistant prompts Add verified June knowledge, guest/member/admin Copilot profiles, profile-isolated session recovery, animated Epi greetings, and a single authoritative runtime config workflow for inference. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 10:50:30 -0700
parents
children
comparison
equal deleted inserted replaced
264:04fee26ecce0 265:056790c4fb0d
1 """
2 Stdlib-only deterministic loader and compiler for the assistant knowledge base.
3
4 Loads prompt files from mrjunejune/assistant/ and compiles them with verified
5 public facts into a hashed, deterministic system prompt. Fails closed on any
6 malformed input.
7
8 Public API:
9 compile_prompt(profile, assistant_dir=None) -> dict
10 Returns {"content": str, "version": int, "hash": str}.
11 Raises ValueError for unknown profile or any validation failure.
12 """
13
14 import hashlib
15 import json
16 import pathlib
17 import re
18 from typing import Optional
19 from urllib.parse import urlsplit
20
21 # ---------------------------------------------------------------------------
22 # Constants
23 # ---------------------------------------------------------------------------
24
25 _KNOWN_PROFILES: frozenset = frozenset({"public_visitor", "invited_friend", "june_admin"})
26
27 _REQUIRED_TOP_KEYS: frozenset = frozenset({"version", "facts"})
28 _REQUIRED_FACT_KEYS: frozenset = frozenset(
29 {"id", "topic", "text", "sourceLabel", "sourceUrl", "visibility", "status"}
30 )
31
32 _EXPECTED_VERSION: int = 1
33 _MAX_PROMPT_FILE_BYTES: int = 8192 # 8 KB per prompt file
34 _MAX_COMPILED_BYTES: int = 16384 # 16 KB total compiled ceiling
35
36 _MAX_ID_LEN: int = 64
37 _MAX_TOPIC_LEN: int = 64
38 _MAX_TEXT_LEN: int = 500
39 _MAX_SOURCE_LABEL_LEN: int = 128
40
41 _SLUG_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
42
43 # Contact patterns: email and North-American-style phone numbers
44 _CONTACT_PATTERNS: list = [
45 re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}"),
46 re.compile(r"(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}"),
47 ]
48 _SECRET_VALUE_PATTERNS: list = [
49 re.compile(
50 r"\b(password|passwd|secret|token|api[_\-]?key|private[_\-]?key)"
51 r"\s*[:=]\s*\S+",
52 re.IGNORECASE,
53 ),
54 re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
55 ]
56 _CONTROL_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
57 _PROMPT_CONTROL_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
58 _RESERVED_CORPUS_PATTERNS: tuple[str, ...] = (
59 "--- knowledge corpus ---",
60 "system:",
61 "assistant:",
62 "user:",
63 "<system",
64 "ignore previous instructions",
65 "ignore prior instructions",
66 "follow these instructions",
67 )
68
69 # Credential / secret keyword patterns
70 _SECRET_PATTERNS: list = [
71 re.compile(
72 r"\b(password|passwd|secret|token|api[_\-]?key|private[_\-]?key)\b",
73 re.IGNORECASE,
74 ),
75 ]
76
77 _FACTS_DELIMITER: str = "--- KNOWLEDGE CORPUS ---"
78
79
80 # ---------------------------------------------------------------------------
81 # Internal helpers
82 # ---------------------------------------------------------------------------
83
84
85 def _default_assistant_dir() -> pathlib.Path:
86 return pathlib.Path(__file__).parent.parent / "assistant"
87
88
89 def _validate_source_url(url: str, fact_id: str) -> None:
90 if not isinstance(url, str) or not url:
91 raise ValueError(f"Fact '{fact_id}': sourceUrl is empty")
92 if _CONTROL_PATTERN.search(url) or "\\" in url:
93 raise ValueError(f"Fact '{fact_id}': sourceUrl contains unsafe characters")
94 if url.startswith("/"):
95 if url.startswith("//") or any(
96 segment in (".", "..") for segment in url.split("/")
97 ):
98 raise ValueError(
99 f"Fact '{fact_id}': sourceUrl must be a safe local path"
100 )
101 return
102 parsed = urlsplit(url)
103 if (
104 parsed.scheme != "https"
105 or not parsed.hostname
106 or parsed.username is not None
107 or parsed.password is not None
108 ):
109 raise ValueError(
110 f"Fact '{fact_id}': sourceUrl must be HTTPS or a safe local path"
111 )
112 try:
113 parsed.port
114 except ValueError as exc:
115 raise ValueError(f"Fact '{fact_id}': sourceUrl has an invalid port") from exc
116
117
118 def _check_no_contact_or_secret(text: str, fact_id: str, field: str) -> None:
119 for pat in _CONTACT_PATTERNS:
120 if pat.search(text):
121 raise ValueError(
122 f"Fact '{fact_id}': {field} contains contact data (email or phone number)"
123 )
124 for pat in _SECRET_PATTERNS:
125 if pat.search(text):
126 raise ValueError(
127 f"Fact '{fact_id}': {field} contains a secret or credential pattern"
128 )
129
130 def _validate_corpus_text(text: str, fact_id: str, field: str) -> None:
131 if _CONTROL_PATTERN.search(text):
132 raise ValueError(f"Fact '{fact_id}': {field} contains control characters")
133 lowered = text.lower()
134 for reserved in _RESERVED_CORPUS_PATTERNS:
135 if reserved in lowered:
136 raise ValueError(
137 f"Fact '{fact_id}': {field} contains reserved prompt instructions"
138 )
139 _check_no_contact_or_secret(text, fact_id, field)
140
141
142 def _load_and_validate_facts(assistant_dir: pathlib.Path) -> list:
143 facts_path = assistant_dir / "knowledge" / "public_facts.json"
144 try:
145 raw = facts_path.read_text(encoding="utf-8")
146 except OSError as exc:
147 raise ValueError(f"Cannot read facts file: {exc}") from exc
148
149 try:
150 data = json.loads(raw)
151 except json.JSONDecodeError as exc:
152 raise ValueError(f"Facts file is not valid JSON: {exc}") from exc
153
154 if not isinstance(data, dict):
155 raise ValueError("Facts file top level must be a JSON object")
156
157 extra_keys = set(data.keys()) - _REQUIRED_TOP_KEYS
158 missing_keys = _REQUIRED_TOP_KEYS - set(data.keys())
159 if extra_keys or missing_keys:
160 raise ValueError(
161 f"Facts file top-level keys must be exactly {sorted(_REQUIRED_TOP_KEYS)}; "
162 f"extra={sorted(extra_keys)}, missing={sorted(missing_keys)}"
163 )
164
165 if (
166 not isinstance(data["version"], int)
167 or isinstance(data["version"], bool)
168 or data["version"] != _EXPECTED_VERSION
169 ):
170 raise ValueError(
171 f"Facts file version must be {_EXPECTED_VERSION}; got {data['version']!r}"
172 )
173
174 raw_facts = data["facts"]
175 if not isinstance(raw_facts, list) or len(raw_facts) == 0:
176 raise ValueError("Facts file 'facts' must be a non-empty list")
177
178 seen_ids: set = set()
179 validated: list = []
180
181 for i, fact in enumerate(raw_facts):
182 if not isinstance(fact, dict):
183 raise ValueError(f"Fact at index {i} must be a JSON object")
184
185 extra = set(fact.keys()) - _REQUIRED_FACT_KEYS
186 missing = _REQUIRED_FACT_KEYS - set(fact.keys())
187 if extra or missing:
188 raise ValueError(
189 f"Fact at index {i}: keys must be exactly "
190 f"{sorted(_REQUIRED_FACT_KEYS)}; "
191 f"extra={sorted(extra)}, missing={sorted(missing)}"
192 )
193
194 fid = fact["id"]
195 if not isinstance(fid, str) or not fid:
196 raise ValueError(f"Fact at index {i}: 'id' must be a non-empty string")
197 if len(fid) > _MAX_ID_LEN:
198 raise ValueError(f"Fact '{fid}': id exceeds {_MAX_ID_LEN} characters")
199 if not _SLUG_RE.match(fid):
200 raise ValueError(
201 f"Fact '{fid}': id must be lowercase alphanumeric with hyphens"
202 )
203 if fid in seen_ids:
204 raise ValueError(f"Duplicate fact id: {fid!r}")
205 seen_ids.add(fid)
206
207 topic = fact["topic"]
208 if not isinstance(topic, str) or not topic:
209 raise ValueError(f"Fact '{fid}': 'topic' must be a non-empty string")
210 if len(topic) > _MAX_TOPIC_LEN:
211 raise ValueError(f"Fact '{fid}': topic exceeds {_MAX_TOPIC_LEN} characters")
212 if not _SLUG_RE.match(topic):
213 raise ValueError(
214 f"Fact '{fid}': topic must be lowercase alphanumeric with hyphens"
215 )
216
217 text = fact["text"]
218 if not isinstance(text, str) or len(text) < 10:
219 raise ValueError(
220 f"Fact '{fid}': 'text' must be a string of at least 10 characters"
221 )
222 if len(text) > _MAX_TEXT_LEN:
223 raise ValueError(
224 f"Fact '{fid}': text exceeds {_MAX_TEXT_LEN} characters"
225 )
226 _validate_corpus_text(text, fid, "text")
227
228 source_label = fact["sourceLabel"]
229 if not isinstance(source_label, str) or not source_label:
230 raise ValueError(f"Fact '{fid}': 'sourceLabel' must be a non-empty string")
231 if len(source_label) > _MAX_SOURCE_LABEL_LEN:
232 raise ValueError(
233 f"Fact '{fid}': sourceLabel exceeds {_MAX_SOURCE_LABEL_LEN} characters"
234 )
235 _validate_corpus_text(source_label, fid, "sourceLabel")
236
237 _validate_source_url(fact["sourceUrl"], fid)
238
239 if not isinstance(fact["visibility"], str) or fact["visibility"] != "public":
240 raise ValueError(
241 f"Fact '{fid}': visibility must be 'public'; got {fact['visibility']!r}"
242 )
243 if not isinstance(fact["status"], str) or fact["status"] != "verified":
244 raise ValueError(
245 f"Fact '{fid}': status must be 'verified'; got {fact['status']!r}"
246 )
247
248 validated.append(fact)
249
250 validated.sort(key=lambda f: f["id"])
251 return validated
252
253
254 def _load_prompt_file(path: pathlib.Path) -> str:
255 try:
256 raw_bytes = path.read_bytes()
257 except OSError as exc:
258 raise ValueError(f"Cannot read prompt file {path.name}: {exc}") from exc
259 if len(raw_bytes) > _MAX_PROMPT_FILE_BYTES:
260 raise ValueError(
261 f"Prompt file {path.name} exceeds {_MAX_PROMPT_FILE_BYTES} bytes "
262 f"(got {len(raw_bytes)})"
263 )
264 try:
265 content = raw_bytes.decode("utf-8")
266 except UnicodeDecodeError as exc:
267 raise ValueError(f"Prompt file {path.name} is not valid UTF-8") from exc
268 if _PROMPT_CONTROL_PATTERN.search(content):
269 raise ValueError(f"Prompt file {path.name} contains control characters")
270 for pattern in _CONTACT_PATTERNS + _SECRET_VALUE_PATTERNS:
271 if pattern.search(content):
272 raise ValueError(
273 f"Prompt file {path.name} contains contact data or a secret value"
274 )
275 return content
276
277
278 def _format_facts(facts: list) -> str:
279 lines: list = []
280 for fact in facts:
281 lines.append(
282 json.dumps(
283 {
284 "fact": fact["text"],
285 "id": fact["id"],
286 "sourceLabel": fact["sourceLabel"],
287 "sourceUrl": fact["sourceUrl"],
288 "topic": fact["topic"],
289 },
290 ensure_ascii=False,
291 separators=(",", ":"),
292 sort_keys=True,
293 )
294 )
295 return "\n".join(lines)
296
297
298 # ---------------------------------------------------------------------------
299 # Public API
300 # ---------------------------------------------------------------------------
301
302
303 def compile_prompt(
304 profile: str,
305 assistant_dir: Optional[pathlib.Path] = None,
306 ) -> dict:
307 """
308 Compile a deterministic system prompt for *profile*.
309
310 Parameters
311 ----------
312 profile:
313 One of ``"public_visitor"``, ``"invited_friend"``, or ``"june_admin"``.
314 assistant_dir:
315 Path to the ``mrjunejune/assistant/`` directory. Defaults to the
316 sibling of the ``inference/`` package at runtime.
317
318 Returns
319 -------
320 dict with keys:
321 - ``"content"`` (str): compiled prompt text
322 - ``"version"`` (int): schema version (always 1)
323 - ``"hash"`` (str): lowercase hex SHA-256 of ``content`` encoded UTF-8
324
325 Raises
326 ------
327 ValueError
328 On unknown profile, malformed files, validation failures, or size
329 limit exceeded.
330 """
331 if profile not in _KNOWN_PROFILES:
332 raise ValueError(
333 f"Unknown profile {profile!r}; "
334 f"known profiles: {sorted(_KNOWN_PROFILES)}"
335 )
336
337 base = pathlib.Path(assistant_dir) if assistant_dir is not None else _default_assistant_dir()
338
339 common = _load_prompt_file(base / "common.md")
340 profile_section = _load_prompt_file(base / f"{profile}.md")
341 facts = _load_and_validate_facts(base)
342 facts_block = _format_facts(facts)
343
344 content = (
345 common.rstrip()
346 + "\n\n"
347 + profile_section.rstrip()
348 + "\n\n"
349 + _FACTS_DELIMITER
350 + "\n\n"
351 + facts_block
352 )
353
354 encoded = content.encode("utf-8")
355 if len(encoded) > _MAX_COMPILED_BYTES:
356 raise ValueError(
357 f"Compiled prompt exceeds {_MAX_COMPILED_BYTES} bytes "
358 f"(got {len(encoded)})"
359 )
360
361 return {
362 "content": content,
363 "version": _EXPECTED_VERSION,
364 "hash": hashlib.sha256(encoded).hexdigest(),
365 }