askpanel 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
askpanel/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """AskPanel — in-app help chat and guided feature requests, grounded in your corpus.
2
+
3
+ Quickstart::
4
+
5
+ from askpanel import AskPanelConfig, create_router
6
+
7
+ config = AskPanelConfig(
8
+ product_name="Orchard",
9
+ corpus_dir="help/",
10
+ user_dependency=current_user,
11
+ on_escalate=save_feedback,
12
+ )
13
+ app.include_router(create_router(config), prefix="/api/askpanel")
14
+ """
15
+
16
+ from .config import AskPanelConfig
17
+ from .corpus import (
18
+ DEFAULT_BANNED_WORDS,
19
+ LintIssue,
20
+ estimate_tokens,
21
+ lint_corpus,
22
+ lint_text,
23
+ load_corpus,
24
+ system_blocks,
25
+ )
26
+ from .prompts import DEFAULT_INTERVIEW_AGENDA
27
+ from .protocol import (
28
+ PROTOCOL_HEADER,
29
+ PROTOCOL_VERSION,
30
+ ChatRequest,
31
+ DeltaFrame,
32
+ DoneFrame,
33
+ ErrorFrame,
34
+ EscalateRequest,
35
+ EscalationPayload,
36
+ EscalationResult,
37
+ Message,
38
+ StatusOut,
39
+ SummarizeRequest,
40
+ SummaryOut,
41
+ plain_text,
42
+ )
43
+ from .provider import (
44
+ AnthropicProvider,
45
+ Provider,
46
+ ProviderCall,
47
+ ProviderCheck,
48
+ ProviderError,
49
+ StubProvider,
50
+ Usage,
51
+ )
52
+ from .quota import DailyTurnCap, MemoryCounter
53
+ from .router import QuotaExceeded, call_host, create_router
54
+ from .sinks import github_issue, webhook
55
+
56
+ __version__ = "0.1.5"
57
+
58
+ __all__ = [
59
+ "__version__",
60
+ "AskPanelConfig",
61
+ "create_router",
62
+ "QuotaExceeded",
63
+ "call_host",
64
+ # protocol
65
+ "PROTOCOL_VERSION",
66
+ "PROTOCOL_HEADER",
67
+ "Message",
68
+ "ChatRequest",
69
+ "SummarizeRequest",
70
+ "SummaryOut",
71
+ "EscalateRequest",
72
+ "EscalationPayload",
73
+ "EscalationResult",
74
+ "StatusOut",
75
+ "DeltaFrame",
76
+ "DoneFrame",
77
+ "ErrorFrame",
78
+ # corpus
79
+ "load_corpus",
80
+ "lint_corpus",
81
+ "lint_text",
82
+ "LintIssue",
83
+ "system_blocks",
84
+ "estimate_tokens",
85
+ "DEFAULT_BANNED_WORDS",
86
+ "DEFAULT_INTERVIEW_AGENDA",
87
+ # providers
88
+ "Provider",
89
+ "ProviderCall",
90
+ "ProviderCheck",
91
+ "ProviderError",
92
+ "DailyTurnCap",
93
+ "MemoryCounter",
94
+ "plain_text",
95
+ "AnthropicProvider",
96
+ "StubProvider",
97
+ "Usage",
98
+ # sinks
99
+ "github_issue",
100
+ "webhook",
101
+ ]
askpanel/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ """``askpanel`` command line: lint a corpus, print the assembled prompt, run the demo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import corpus as corpus_mod
11
+ from . import prompts
12
+
13
+
14
+ def _cmd_lint(args: argparse.Namespace) -> int:
15
+ banned = tuple(args.ban) if args.ban else corpus_mod.DEFAULT_BANNED_WORDS
16
+ if args.allow:
17
+ banned = tuple(w for w in banned if w.lower() not in {a.lower() for a in args.allow})
18
+ try:
19
+ issues = corpus_mod.lint_corpus(args.dir, banned=banned, min_chars=args.min_chars)
20
+ except FileNotFoundError as e:
21
+ print(e, file=sys.stderr)
22
+ return 2
23
+ for issue in issues:
24
+ print(issue)
25
+ errors = sum(1 for i in issues if i.severity == "error")
26
+ warnings = len(issues) - errors
27
+ files = len(corpus_mod.corpus_files(args.dir))
28
+ print(f"{files} file(s), {errors} error(s), {warnings} warning(s)")
29
+ return 1 if errors else 0
30
+
31
+
32
+ def _cmd_prompt(args: argparse.Namespace) -> int:
33
+ try:
34
+ text = corpus_mod.load_corpus(args.dir)
35
+ except FileNotFoundError as e:
36
+ print(e, file=sys.stderr)
37
+ return 2
38
+ if args.mode == "help":
39
+ instructions = prompts.help_instructions(args.product)
40
+ elif args.mode == "interview":
41
+ instructions = prompts.interview_instructions(args.product)
42
+ else:
43
+ instructions = prompts.summarize_instructions(args.product)
44
+ blocks = corpus_mod.system_blocks(text, args.product, instructions)
45
+ assembled = corpus_mod.assembled_prompt(blocks)
46
+ print(assembled)
47
+ corpus_chars = len(blocks[0]["text"])
48
+ print(
49
+ f"\n--- {len(assembled)} characters, ~{corpus_mod.estimate_tokens(assembled)} tokens "
50
+ f"(cached corpus block: {corpus_chars} characters, "
51
+ f"~{corpus_mod.estimate_tokens(blocks[0]['text'])} tokens; estimate = chars/4)",
52
+ file=sys.stderr,
53
+ )
54
+ if len(text) < corpus_mod.RECOMMENDED_MIN_CHARS:
55
+ print(
56
+ f"warning: corpus is {len(text)} characters; aim above "
57
+ f"{corpus_mod.RECOMMENDED_MIN_CHARS} so prompt caching engages",
58
+ file=sys.stderr,
59
+ )
60
+ return 0
61
+
62
+
63
+ def _find_demo_dir(explicit: str | None) -> Path | None:
64
+ if explicit: # an explicit --dir is authoritative; don't fall back to guessing
65
+ d = Path(explicit)
66
+ return d if (d / "server.py").is_file() else None
67
+ candidates: list[Path] = []
68
+ if os.environ.get("ASKPANEL_DEMO_DIR"):
69
+ candidates.append(Path(os.environ["ASKPANEL_DEMO_DIR"]))
70
+ here = Path.cwd()
71
+ for base in [here, *here.parents]:
72
+ candidates.append(base / "examples" / "demo")
73
+ pkg = Path(__file__).resolve()
74
+ for base in pkg.parents:
75
+ candidates.append(base / "examples" / "demo")
76
+ for c in candidates:
77
+ if (c / "server.py").is_file():
78
+ return c
79
+ return None
80
+
81
+
82
+ def _cmd_serve_demo(args: argparse.Namespace) -> int:
83
+ demo = _find_demo_dir(args.dir)
84
+ if demo is None:
85
+ print(
86
+ "Could not find examples/demo/server.py. Run this from a checkout of the "
87
+ "askpanel repository, pass --dir <path-to-examples/demo>, or set "
88
+ "ASKPANEL_DEMO_DIR.",
89
+ file=sys.stderr,
90
+ )
91
+ return 2
92
+ try:
93
+ import uvicorn
94
+ except ImportError:
95
+ print(
96
+ "uvicorn is not installed. Install with: pip install 'askpanel[demo]'", file=sys.stderr
97
+ )
98
+ return 2
99
+ sys.path.insert(0, str(demo))
100
+ os.environ.setdefault("ASKPANEL_DEMO_DIR", str(demo))
101
+ print(f"Serving demo from {demo} on http://{args.host}:{args.port}")
102
+ if not (demo / "web" / "dist" / "index.html").is_file():
103
+ print(
104
+ "note: examples/demo/web/dist is missing; the API works but the page is "
105
+ "not built. Run `npm install && npm run build` in examples/demo/web.",
106
+ file=sys.stderr,
107
+ )
108
+ uvicorn.run("server:app", host=args.host, port=args.port, reload=False)
109
+ return 0
110
+
111
+
112
+ def build_parser() -> argparse.ArgumentParser:
113
+ p = argparse.ArgumentParser(prog="askpanel", description="AskPanel corpus tools")
114
+ sub = p.add_subparsers(dest="command", required=True)
115
+
116
+ lint = sub.add_parser("lint", help="check a corpus directory; exit 1 on errors")
117
+ lint.add_argument("dir")
118
+ lint.add_argument("--ban", action="append", help="replace the banned word list (repeatable)")
119
+ lint.add_argument("--allow", action="append", help="remove a word from the banned list")
120
+ lint.add_argument("--min-chars", type=int, default=corpus_mod.RECOMMENDED_MIN_CHARS)
121
+ lint.set_defaults(func=_cmd_lint)
122
+
123
+ prompt = sub.add_parser("prompt", help="print the assembled system prompt and a token estimate")
124
+ prompt.add_argument("dir")
125
+ prompt.add_argument("--product", default="the product", help="product name used in the prompt")
126
+ prompt.add_argument("--mode", choices=["help", "interview", "summarize"], default="help")
127
+ prompt.set_defaults(func=_cmd_prompt)
128
+
129
+ demo = sub.add_parser("serve-demo", help="run the example app (needs uvicorn)")
130
+ demo.add_argument("--dir", help="path to examples/demo (auto-detected in a checkout)")
131
+ demo.add_argument("--host", default="127.0.0.1")
132
+ demo.add_argument("--port", type=int, default=8765)
133
+ demo.set_defaults(func=_cmd_serve_demo)
134
+ return p
135
+
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ args = build_parser().parse_args(argv)
139
+ return int(args.func(args))
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
askpanel/config.py ADDED
@@ -0,0 +1,156 @@
1
+ """``AskPanelConfig`` — every knob of the server side, in one object (SPEC §5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from . import corpus as corpus_mod
11
+ from . import prompts
12
+ from .protocol import (
13
+ DEFAULT_MAX_MESSAGE_CHARS,
14
+ DEFAULT_MAX_MESSAGES,
15
+ MAX_CONTEXT_CHARS,
16
+ MODES,
17
+ )
18
+ from .provider import AnthropicProvider, Provider, Usage, provider_configured
19
+
20
+ # Host callbacks may be ``async def`` or plain ``def`` (sync ones run in a threadpool).
21
+ OnEscalate = Callable[
22
+ ..., Awaitable[Any] | Any
23
+ ] # (payload, user[, request]) -> EscalationResult | dict | str | None
24
+ Quota = Callable[..., Awaitable[Any] | Any] # (user[, mode]) -> bool | str
25
+ OnTurn = Callable[[Any, str, Usage | None], Awaitable[None] | None]
26
+ ContextValidator = Callable[[str], bool]
27
+
28
+
29
+ @dataclass
30
+ class AskPanelConfig:
31
+ """Configuration for ``create_router``. See docs/configuration.md for every option.
32
+
33
+ Required: ``product_name``, ``user_dependency``, ``on_escalate``, and one of
34
+ ``corpus_dir`` / ``corpus_text``.
35
+ """
36
+
37
+ product_name: str
38
+ user_dependency: Callable[..., Any]
39
+ on_escalate: OnEscalate
40
+ corpus_dir: str | Path | None = None
41
+ corpus_text: str | None = None
42
+ allowed_contexts: Iterable[str] | None = None
43
+ context_validator: ContextValidator | None = None
44
+ provider: Provider | None = None
45
+ extra_instructions: str = ""
46
+ interview_agenda: Sequence[str] = prompts.DEFAULT_INTERVIEW_AGENDA
47
+ interview_max_turns: int = 6
48
+ max_messages: int = DEFAULT_MAX_MESSAGES
49
+ max_message_chars: int = DEFAULT_MAX_MESSAGE_CHARS
50
+ quota: Quota | None = None
51
+ starters: dict[str, list[str]] = field(default_factory=dict)
52
+ modes: Iterable[str] = ("help", "interview")
53
+ on_turn: OnTurn | None = None
54
+
55
+ def __post_init__(self) -> None:
56
+ if not self.product_name or not self.product_name.strip():
57
+ raise ValueError("product_name is required")
58
+ if self.corpus_dir is None and self.corpus_text is None:
59
+ raise ValueError("one of corpus_dir or corpus_text is required")
60
+ if self.corpus_text is None:
61
+ self.corpus_text = corpus_mod.load_corpus(self.corpus_dir) # type: ignore[arg-type]
62
+ self.corpus_text = self.corpus_text.strip()
63
+ if self.provider is None:
64
+ self.provider = AnthropicProvider()
65
+ bad = [m for m in self.modes if m not in MODES]
66
+ if bad:
67
+ raise ValueError(f"unknown modes {bad!r}; valid modes are {list(MODES)}")
68
+ self.modes = tuple(m for m in MODES if m in set(self.modes))
69
+ if not self.modes:
70
+ raise ValueError("at least one mode must be enabled")
71
+ if self.allowed_contexts is not None:
72
+ self.allowed_contexts = tuple(self.allowed_contexts)
73
+ if self.interview_max_turns < 1:
74
+ raise ValueError("interview_max_turns must be >= 1")
75
+ if self.max_messages < 1 or self.max_message_chars < 1:
76
+ raise ValueError("max_messages and max_message_chars must be >= 1")
77
+ for ctx in self.starters:
78
+ if len(ctx) > MAX_CONTEXT_CHARS:
79
+ raise ValueError(f"starters key {ctx!r} exceeds {MAX_CONTEXT_CHARS} chars")
80
+
81
+ # -- derived ---------------------------------------------------------------------
82
+
83
+ @property
84
+ def enabled(self) -> bool:
85
+ """Provider configured AND corpus non-empty. Drives ``/status`` and the 503s.
86
+
87
+ "Configured" means credentials are *present*, not valid: no network is touched.
88
+ Use ``verify()`` at startup to find a revoked key or a wrong model id.
89
+ """
90
+ return bool(self.corpus_text) and provider_configured(self.provider)
91
+
92
+ def verify(self) -> list[str]:
93
+ """Check the parts of the configuration that ``enabled`` cannot: an empty corpus,
94
+ and — when the provider offers ``check()`` — the key and model id, with one
95
+ free request. Returns a list of problems (empty means everything is fine).
96
+ Never called by the router; call it at startup, in a health check, or a test."""
97
+ problems: list[str] = []
98
+ if not self.corpus_text:
99
+ problems.append("corpus is empty")
100
+ if not provider_configured(self.provider):
101
+ problems.append("provider has no credentials (ANTHROPIC_API_KEY or api_key=)")
102
+ elif hasattr(self.provider, "check"):
103
+ result = self.provider.check()
104
+ if not getattr(result, "ok", False):
105
+ problems.append(
106
+ f"provider check failed for model {getattr(result, 'model', '?')}: "
107
+ f"{getattr(result, 'error', 'unknown error')}"
108
+ )
109
+ return problems
110
+
111
+ def context_ok(self, context: str | None) -> bool:
112
+ """Is this ``context`` acceptable? ``None`` always is."""
113
+ if context is None:
114
+ return True
115
+ if len(context) > MAX_CONTEXT_CHARS:
116
+ return False
117
+ if self.context_validator is not None and not self.context_validator(context):
118
+ return False
119
+ if self.allowed_contexts is not None and context not in self.allowed_contexts:
120
+ return False
121
+ return True
122
+
123
+ def instructions_for(self, mode: str, conversation_mode: str = "interview") -> str:
124
+ """The instruction block for ``mode`` (``"help"``, ``"interview"``, or
125
+ ``"summarize"``). For ``"summarize"``, ``conversation_mode`` picks the shape:
126
+ a question-shaped note for ``"help"``, a request for ``"interview"``."""
127
+ if mode == "help":
128
+ return prompts.help_instructions(self.product_name, self.extra_instructions)
129
+ if mode == "interview":
130
+ return prompts.interview_instructions(
131
+ self.product_name,
132
+ self.interview_agenda,
133
+ self.interview_max_turns,
134
+ self.extra_instructions,
135
+ )
136
+ if mode == "summarize":
137
+ return prompts.summarize_instructions(self.product_name, conversation_mode)
138
+ raise ValueError(f"unknown mode {mode!r}")
139
+
140
+ def system_blocks(self, mode: str, conversation_mode: str = "interview") -> list[dict]:
141
+ """Cached corpus block + instructions for ``mode`` (see ``instructions_for``)."""
142
+ return corpus_mod.system_blocks(
143
+ self.corpus_text or "",
144
+ self.product_name,
145
+ self.instructions_for(mode, conversation_mode),
146
+ )
147
+
148
+ async def averify(self) -> list[str]:
149
+ """``verify()`` for async code (a FastAPI lifespan): runs it in a worker thread so
150
+ the provider round-trip never blocks the event loop."""
151
+ import asyncio
152
+
153
+ return await asyncio.to_thread(self.verify)
154
+
155
+
156
+ __all__ = ["AskPanelConfig", "OnEscalate", "Quota", "OnTurn", "ContextValidator"]
askpanel/corpus.py ADDED
@@ -0,0 +1,221 @@
1
+ """Corpus loading, linting, and the cached system block.
2
+
3
+ The corpus is the feature: a set of markdown files the host writes in its users'
4
+ vocabulary. ``load_corpus`` joins them in filename order; ``lint_corpus`` catches the
5
+ mistakes that make a help assistant confidently wrong; ``system_blocks`` turns the text
6
+ into the cached system prompt prefix, byte-for-byte identical on every call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from functools import lru_cache
14
+ from pathlib import Path
15
+
16
+ from . import prompts
17
+
18
+ CORPUS_SEPARATOR = "\n\n---\n\n"
19
+ CORPUS_GLOB = "*.md"
20
+
21
+ DEFAULT_BANNED_WORDS: tuple[str, ...] = (
22
+ "endpoint",
23
+ "database",
24
+ "postgres",
25
+ "jsonb",
26
+ "migration",
27
+ "alembic",
28
+ "api",
29
+ "backend",
30
+ "frontend",
31
+ "deploy",
32
+ "env var",
33
+ )
34
+
35
+ #: Below this many characters the assembled prompt is unlikely to reach the provider's
36
+ #: minimum cacheable prefix; the linter warns (does not fail).
37
+ RECOMMENDED_MIN_CHARS = 8000
38
+
39
+ #: Rough tokens-per-character ratio for English prose. An estimate, not a count.
40
+ CHARS_PER_TOKEN = 4
41
+
42
+
43
+ def corpus_files(corpus_dir: str | Path) -> list[Path]:
44
+ """The markdown files of a corpus directory, sorted by filename."""
45
+ d = Path(corpus_dir)
46
+ if not d.is_dir():
47
+ raise FileNotFoundError(f"corpus directory not found: {d}")
48
+ return sorted(p for p in d.glob(CORPUS_GLOB) if p.is_file())
49
+
50
+
51
+ def load_corpus(corpus_dir: str | Path) -> str:
52
+ """Read every ``*.md`` in ``corpus_dir`` in filename order, joined by a separator.
53
+
54
+ Files are stripped of surrounding whitespace so the result is stable across editors.
55
+ Returns ``""`` for an empty directory (the module then reports itself disabled).
56
+ """
57
+ texts = [p.read_text(encoding="utf-8").strip() for p in corpus_files(corpus_dir)]
58
+ return CORPUS_SEPARATOR.join(t for t in texts if t)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class LintIssue:
63
+ """One finding from ``lint_corpus``. ``severity`` is ``"error"`` or ``"warning"``."""
64
+
65
+ file: str
66
+ line: int # 1-based; 0 when the finding is about the whole file or corpus
67
+ message: str
68
+ severity: str = "error"
69
+
70
+ def __str__(self) -> str:
71
+ where = f"{self.file}:{self.line}" if self.line else self.file
72
+ return f"{where}: {self.severity}: {self.message}"
73
+
74
+
75
+ def _banned_pattern(words: tuple[str, ...] | list[str]) -> re.Pattern[str] | None:
76
+ words = [w.strip() for w in words if w and w.strip()]
77
+ if not words:
78
+ return None
79
+ alts = [re.escape(w).replace(r"\ ", r"\s+") for w in words]
80
+ return re.compile(r"(?<![\w-])(" + "|".join(alts) + r")(?![\w-])", re.IGNORECASE)
81
+
82
+
83
+ def lint_text(
84
+ text: str,
85
+ *,
86
+ filename: str = "<text>",
87
+ banned: tuple[str, ...] | list[str] = DEFAULT_BANNED_WORDS,
88
+ ) -> list[LintIssue]:
89
+ """Lint one file's text. Rules:
90
+
91
+ - the first non-empty line must be a markdown title (``# ...``)
92
+ - the file must not be empty
93
+ - no banned implementation words (whole-word, case-insensitive)
94
+ """
95
+ issues: list[LintIssue] = []
96
+ lines = text.splitlines()
97
+ first = next(((i, ln) for i, ln in enumerate(lines, start=1) if ln.strip()), None)
98
+ if first is None:
99
+ issues.append(LintIssue(filename, 0, "file is empty"))
100
+ return issues
101
+ line_no, line = first
102
+ if not re.match(r"^#\s+\S", line):
103
+ issues.append(
104
+ LintIssue(
105
+ filename,
106
+ line_no,
107
+ "first line must be a title (`# What this file is about`)",
108
+ )
109
+ )
110
+ pattern = _banned_pattern(banned)
111
+ if pattern is not None:
112
+ for i, ln in enumerate(lines, start=1):
113
+ for m in pattern.finditer(ln):
114
+ issues.append(
115
+ LintIssue(
116
+ filename,
117
+ i,
118
+ f"banned word {m.group(1)!r}: users don't say this; "
119
+ "describe what they see instead",
120
+ )
121
+ )
122
+ return issues
123
+
124
+
125
+ def lint_corpus(
126
+ corpus_dir: str | Path,
127
+ *,
128
+ banned: tuple[str, ...] | list[str] = DEFAULT_BANNED_WORDS,
129
+ min_chars: int = RECOMMENDED_MIN_CHARS,
130
+ ) -> list[LintIssue]:
131
+ """Lint every file in a corpus directory. Errors mean the corpus should not ship.
132
+
133
+ Warnings (``severity == "warning"``): no files at all; total size below
134
+ ``min_chars`` (prompt caching probably will not engage).
135
+ """
136
+ files = corpus_files(corpus_dir)
137
+ issues: list[LintIssue] = []
138
+ if not files:
139
+ return [LintIssue(str(corpus_dir), 0, "no *.md files found", "warning")]
140
+ total = 0
141
+ for p in files:
142
+ text = p.read_text(encoding="utf-8")
143
+ total += len(text.strip())
144
+ issues.extend(lint_text(text, filename=p.name, banned=banned))
145
+ if total < min_chars:
146
+ issues.append(
147
+ LintIssue(
148
+ str(corpus_dir),
149
+ 0,
150
+ f"corpus is {total} characters; aim above {min_chars} so prompt caching engages",
151
+ "warning",
152
+ )
153
+ )
154
+ return issues
155
+
156
+
157
+ def has_errors(issues: list[LintIssue]) -> bool:
158
+ return any(i.severity == "error" for i in issues)
159
+
160
+
161
+ def estimate_tokens(text: str) -> int:
162
+ """A rough token estimate (characters / 4). Good enough to see whether caching engages."""
163
+ return (len(text) + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN
164
+
165
+
166
+ @lru_cache(maxsize=64)
167
+ def corpus_block_text(corpus_text: str, product_name: str) -> str:
168
+ """The text of the cached block: preamble + corpus + postamble."""
169
+ return (
170
+ prompts.CORPUS_PREAMBLE.format(product_name=product_name)
171
+ + corpus_text.strip()
172
+ + prompts.CORPUS_POSTAMBLE
173
+ )
174
+
175
+
176
+ @lru_cache(maxsize=64)
177
+ def _corpus_block(corpus_text: str, product_name: str) -> dict:
178
+ return {
179
+ "type": "text",
180
+ "text": corpus_block_text(corpus_text, product_name),
181
+ "cache_control": {"type": "ephemeral"},
182
+ }
183
+
184
+
185
+ @lru_cache(maxsize=64)
186
+ def _instructions_block(instructions: str) -> dict:
187
+ return {"type": "text", "text": instructions}
188
+
189
+
190
+ def system_blocks(corpus_text: str, product_name: str, instructions: str) -> list[dict]:
191
+ """The system prompt as provider blocks.
192
+
193
+ Block 0 is the corpus with ``cache_control: ephemeral``; it depends only on the
194
+ corpus text and product name, so it is byte-identical across help, interview, and
195
+ summarize requests and across turns. Block 1 is the (uncached) mode instructions.
196
+ Results are memoised; callers get fresh list copies of the same dict objects and
197
+ must not mutate them.
198
+ """
199
+ return [_corpus_block(corpus_text, product_name), _instructions_block(instructions)]
200
+
201
+
202
+ def assembled_prompt(blocks: list[dict]) -> str:
203
+ """Flatten system blocks to one string (for ``askpanel prompt`` and tests)."""
204
+ return "\n\n".join(b["text"] for b in blocks)
205
+
206
+
207
+ __all__ = [
208
+ "CORPUS_SEPARATOR",
209
+ "DEFAULT_BANNED_WORDS",
210
+ "RECOMMENDED_MIN_CHARS",
211
+ "LintIssue",
212
+ "corpus_files",
213
+ "load_corpus",
214
+ "lint_text",
215
+ "lint_corpus",
216
+ "has_errors",
217
+ "estimate_tokens",
218
+ "corpus_block_text",
219
+ "system_blocks",
220
+ "assembled_prompt",
221
+ ]