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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ node_modules/
8
+ .env
9
+ .DS_Store
10
+ coverage/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ examples/demo/web/dist/
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.
@@ -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.
@@ -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"]