askpanel 0.1.6__tar.gz
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-0.1.6/.gitignore +13 -0
- askpanel-0.1.6/LICENSE +19 -0
- askpanel-0.1.6/PKG-INFO +36 -0
- askpanel-0.1.6/README.md +16 -0
- askpanel-0.1.6/pyproject.toml +56 -0
- askpanel-0.1.6/src/askpanel/__init__.py +101 -0
- askpanel-0.1.6/src/askpanel/cli.py +143 -0
- askpanel-0.1.6/src/askpanel/config.py +156 -0
- askpanel-0.1.6/src/askpanel/corpus.py +221 -0
- askpanel-0.1.6/src/askpanel/prompts.py +239 -0
- askpanel-0.1.6/src/askpanel/protocol.py +403 -0
- askpanel-0.1.6/src/askpanel/provider.py +265 -0
- askpanel-0.1.6/src/askpanel/quota.py +146 -0
- askpanel-0.1.6/src/askpanel/router.py +320 -0
- askpanel-0.1.6/src/askpanel/sinks.py +159 -0
- askpanel-0.1.6/tests/conftest.py +76 -0
- askpanel-0.1.6/tests/test_cli.py +32 -0
- askpanel-0.1.6/tests/test_corpus.py +121 -0
- askpanel-0.1.6/tests/test_protocol.py +184 -0
- askpanel-0.1.6/tests/test_quota_and_verify.py +158 -0
- askpanel-0.1.6/tests/test_round4.py +86 -0
- askpanel-0.1.6/tests/test_round5.py +95 -0
- askpanel-0.1.6/tests/test_router.py +636 -0
- askpanel-0.1.6/tests/test_sinks.py +92 -0
- askpanel-0.1.6/uv.lock +593 -0
askpanel-0.1.6/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Paul Gustafson
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
14
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
15
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
16
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
17
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
18
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
19
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
askpanel-0.1.6/PKG-INFO
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: askpanel
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: In-app help chat and guided feature requests, grounded only in a markdown corpus you write.
|
|
5
|
+
Author: Paul Gustafson
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: anthropic,chat,fastapi,feedback,help
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: anthropic>=0.40
|
|
15
|
+
Requires-Dist: fastapi>=0.110
|
|
16
|
+
Requires-Dist: pydantic>=2
|
|
17
|
+
Provides-Extra: demo
|
|
18
|
+
Requires-Dist: uvicorn>=0.23; extra == 'demo'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# askpanel (Python)
|
|
22
|
+
|
|
23
|
+
Server half of [AskPanel](https://github.com/pwgustafson/askpanel): a FastAPI router that
|
|
24
|
+
serves in-app help chat and guided feature requests grounded only in a markdown corpus
|
|
25
|
+
you write. See the repository README for the quickstart and `docs/` for configuration,
|
|
26
|
+
the corpus guide, and the wire protocol.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install askpanel # or: uv add askpanel
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Why:** as more of a product is built by AI agents, the humans on the team stop being
|
|
33
|
+
able to answer "how does this work" and "what would it take to add that" from memory.
|
|
34
|
+
AskPanel lets the same approach explain the product: an assistant that answers only from
|
|
35
|
+
a short corpus written in your users' words, and turns "it doesn't do that" into a
|
|
36
|
+
structured feature request with the whole conversation attached.
|
askpanel-0.1.6/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# askpanel (Python)
|
|
2
|
+
|
|
3
|
+
Server half of [AskPanel](https://github.com/pwgustafson/askpanel): a FastAPI router that
|
|
4
|
+
serves in-app help chat and guided feature requests grounded only in a markdown corpus
|
|
5
|
+
you write. See the repository README for the quickstart and `docs/` for configuration,
|
|
6
|
+
the corpus guide, and the wire protocol.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install askpanel # or: uv add askpanel
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**Why:** as more of a product is built by AI agents, the humans on the team stop being
|
|
13
|
+
able to answer "how does this work" and "what would it take to add that" from memory.
|
|
14
|
+
AskPanel lets the same approach explain the product: an assistant that answers only from
|
|
15
|
+
a short corpus written in your users' words, and turns "it doesn't do that" into a
|
|
16
|
+
structured feature request with the whole conversation attached.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "askpanel"
|
|
3
|
+
version = "0.1.6"
|
|
4
|
+
description = "In-app help chat and guided feature requests, grounded only in a markdown corpus you write."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Paul Gustafson" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"fastapi>=0.110",
|
|
11
|
+
"pydantic>=2",
|
|
12
|
+
"anthropic>=0.40",
|
|
13
|
+
]
|
|
14
|
+
keywords = ["help", "chat", "fastapi", "anthropic", "feedback"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Framework :: FastAPI",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
# `askpanel serve-demo` needs an ASGI server.
|
|
24
|
+
demo = ["uvicorn>=0.23"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
askpanel = "askpanel.cli:main"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8",
|
|
32
|
+
"pytest-asyncio>=0.23",
|
|
33
|
+
"httpx>=0.27", # Starlette's TestClient transport
|
|
34
|
+
"ruff>=0.5",
|
|
35
|
+
"uvicorn>=0.23",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/askpanel"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
addopts = "-q"
|
|
48
|
+
asyncio_mode = "strict"
|
|
49
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
target-version = "py311"
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "W", "UP", "B"]
|
|
@@ -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
|
+
]
|
|
@@ -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())
|
|
@@ -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"]
|