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 +101 -0
- askpanel/cli.py +143 -0
- askpanel/config.py +156 -0
- askpanel/corpus.py +221 -0
- askpanel/prompts.py +239 -0
- askpanel/protocol.py +403 -0
- askpanel/provider.py +265 -0
- askpanel/quota.py +146 -0
- askpanel/router.py +320 -0
- askpanel/sinks.py +159 -0
- askpanel-0.1.6.dist-info/METADATA +36 -0
- askpanel-0.1.6.dist-info/RECORD +15 -0
- askpanel-0.1.6.dist-info/WHEEL +4 -0
- askpanel-0.1.6.dist-info/entry_points.txt +2 -0
- askpanel-0.1.6.dist-info/licenses/LICENSE +19 -0
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
|
+
]
|