From 15907070a035294e4d09ebbf3452dadd6ca5a982 Mon Sep 17 00:00:00 2001 From: doxa Date: Sat, 20 Jun 2026 13:20:22 +0100 Subject: [PATCH] feat: optional Hawking presentation mode for grounded answers doxa retrieves evidence; the agent reading it composes the prose answer (doxa query --answer is the built-in renderer). This adds an optional presentation layer for that final step, without touching the verbatim-grounding guarantee. - doxa/present.py: registry-based PresentationProfile system. Default "plain" (output unchanged) plus "hawking", distilled from how Stephen Hawking presented evidence in the first two chapters of A Brief History of Time: open on the old question, stage a procession of minds, say the largest thing plainly, keep the strangeness intact, make the answer epistemology. Each move carries a short verbatim exemplar. - CLI: "doxa query --present hawking" prepends a composition directive to the evidence (pairs with --answer); new "doxa present" subcommand views and lists profiles; "presentation.default" config key. JSON stays a bare list for plain and wraps as {presentation, results} for non-plain (backward compatible). - The mode changes voice and shape only. The directive restates the inviolable rule: answer only from returned beliefs and quotes, report conviction honestly, invent nothing. - Docs (README, AGENTS.md, docs/presentation.md, architecture), the portable skill (both copies), and example config updated. Tests in tests/test_present.py (16 tests); full suite 129 passed. Also fix the pre-existing red CI so this lands green across Python 3.10-3.13: - resources.py imported importlib.resources.abc at runtime, which only exists on 3.11+; it is annotation-only, so guard it under TYPE_CHECKING (fixes the 3.10 collection error). - type the lens init-answers dict as dict[str, Any] (stances/tags are lists) and the pack source-dedup loop as Belief | Quote (mypy). - mark the scheme-validated pack urlopen with nosec B310 (bandit). Now clean under ruff, mypy, bandit, pytest, coverage (83%), build, twine. --- .gitignore | 2 + AGENTS.md | 16 +++ README.md | 30 ++++- docs/architecture.md | 11 ++ docs/presentation.md | 89 +++++++++++++ doxa.example.yaml | 9 ++ doxa/_assets/skill/SKILL.md | 18 +++ doxa/cli.py | 57 +++++++- doxa/config.py | 5 + doxa/packs.py | 6 +- doxa/present.py | 259 ++++++++++++++++++++++++++++++++++++ doxa/resources.py | 8 +- skill/SKILL.md | 18 +++ tests/test_cli.py | 5 +- tests/test_present.py | 161 ++++++++++++++++++++++ 15 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 docs/presentation.md create mode 100644 doxa/present.py create mode 100644 tests/test_present.py diff --git a/.gitignore b/.gitignore index 120b7f9..87b86ef 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +.coverage +htmlcov/ .venv/ .pip-cache/ venv/ diff --git a/AGENTS.md b/AGENTS.md index 747116b..bd544c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ users often don't know what's available. | Ingest | `doxa ingest ` | | Hard pages | `doxa ingest --via jina\|firecrawl\|brightdata\|claude\|codex\|hermes` `[--mode browser\|extract --prompt ...] [--yolo]` | | Answer | `doxa query "" --answer` / `--json` / `--domain ` / `--search hybrid` | +| Voice (optional) | `doxa query "" --present hawking` -- answer as a quest; `doxa present --list` | | Honest | `doxa eval` · `doxa sources list\|remove ` · `doxa domains set <0-10>` | | Agent skill | `doxa skill install --harness claude-code\|codex\|hermes\|openclaw` | @@ -223,6 +224,21 @@ sources: argv: ["my-mcp-fetch", "{url}"] ``` +## Presentation Modes + +By default doxa returns evidence and you write the answer in your own voice. For +reflective or big-question prompts, offer the optional `hawking` voice: + +```bash +doxa present --list # available voices +doxa query "" --answer --present hawking +``` + +A non-plain mode prepends a composition directive to the query output. Follow it +for voice and shape only -- it never relaxes the grounding rule in Discipline +below. Set `presentation.default` in `doxa.yaml` to make a mode sticky. See +[docs/presentation.md](docs/presentation.md). + ## Install As A Harness Skill Offer to register doxa as a skill for the user's harness: diff --git a/README.md b/README.md index 34b49f7..844c9e2 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Keyword search works with no API key, database, embedding model, or network. ## Docs - [Configuration](docs/configuration.md) · [Providers](docs/providers.md) · [Ingestion](docs/ingestion.md) -- [Retrieval](docs/retrieval.md) · [Writing a lens](docs/writing-a-lens.md) · [Schema](docs/schema.md) · [Architecture](docs/architecture.md) +- [Retrieval](docs/retrieval.md) · [Writing a lens](docs/writing-a-lens.md) · [Schema](docs/schema.md) · [Architecture](docs/architecture.md) · [Presentation modes](docs/presentation.md) - [Agent skill](docs/skill.md) · [AGENTS.md](AGENTS.md) · [skill/SKILL.md](skill/SKILL.md) - [Example configs gallery](examples/README.md) -- copy-ready `doxa.yaml` templates - [Example Q&A](docs/examples-qa.md) -- grounded answers on the demo base @@ -103,6 +103,34 @@ doxa query "Should I trust my own judgment over the crowd?" --- +## Presentation modes (optional) + +doxa returns evidence; the agent reading it writes the prose answer. A +**presentation mode** is an optional voice for that final step. The default is +`plain` (output unchanged). The flagship optional mode is `hawking`, distilled +from how Stephen Hawking presented evidence in the first two chapters of *A Brief +History of Time*: open on the ancient question rather than the apparatus, let the +evidence arrive as a procession of minds, say the largest thing in the plainest +sentence, keep the genuine strangeness intact, and turn the answer into a +question about what we can know. + +```bash +doxa present --list # list modes +doxa present hawking # read the composition directive +doxa query "did time have a beginning?" --answer --present hawking +``` + +With a non-plain mode, `doxa query` prints a `=== doxa presentation directive ===` +block before the evidence; an agent reads it and composes in that voice. The mode +changes voice and shape only -- it never loosens the verbatim-grounding rule, so +the answer is still built only from returned beliefs and quotes. With `--json`, a +non-plain mode wraps output as `{"presentation": {...}, "results": [...]}`; `plain` +stays a bare list. Make a mode sticky in `doxa.yaml` with `presentation.default`, +or add your own by registering a `PresentationProfile` in `doxa/present.py`. See +[docs/presentation.md](docs/presentation.md). + +--- + ## Why doxa exists Fluency is where hallucinations hide: a summarizer can compress, overstate, diff --git a/docs/architecture.md b/docs/architecture.md index 0de7ca9..988906b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,18 @@ doxa is deliberately small in the core path: 5. Verify each quote is a real substring of the source after whitespace normalization. 6. Store beliefs, quotes, and source text as JSONL. 7. Retrieve with keyword BM25, optional pgvector semantic search, or hybrid RRF. +8. Optionally emit a presentation directive so the consuming agent composes the answer in a chosen voice. JSONL is the source of truth. Postgres/pgvector is an optional index, not the canonical store. +## Where answers are composed + +doxa retrieves evidence; the agent reading the output composes the prose answer +(`doxa query --answer` is the built-in deterministic renderer). That split is +where presentation modes live. A `PresentationProfile` (`doxa/present.py`) is an +optional composition directive the CLI prints alongside the evidence at query +time. It shapes voice and structure only -- the retrieved beliefs and verbatim +quotes remain the sole evidence, so a mode can never become a license to +embellish. The default is `plain` (no directive). See [presentation.md](presentation.md). + diff --git a/docs/presentation.md b/docs/presentation.md new file mode 100644 index 0000000..f818028 --- /dev/null +++ b/docs/presentation.md @@ -0,0 +1,89 @@ +# Presentation modes + +doxa retrieves evidence; the agent reading the output composes the prose answer. +A **presentation mode** is an optional voice for that final step. It changes the +voice and shape of the answer, never the grounding. + +The default is `plain`: no directive, output unchanged. The flagship optional +mode is `hawking`. + +## Use it + +```bash +doxa present --list # list modes with one-line summaries +doxa present hawking # print the composition directive +doxa present hawking --json # machine-readable profile + +doxa query "did time have a beginning?" --present hawking +doxa query "did time have a beginning?" --answer --present hawking # pairs with the evidence brief +``` + +With a non-plain mode, `doxa query` prints a directive block before the +evidence: + +```text +=== doxa presentation directive: hawking === +... +=== end presentation directive === + +1. ... +``` + +An agent reads the directive, then writes the answer in that voice using only +the retrieved beliefs and quotes. + +### Output shapes + +- Text: the directive is prepended, then the usual numbered results. +- `--json` with `plain`: a bare list of results (unchanged, backward compatible). +- `--json` with a non-plain mode: `{"presentation": {...}, "results": [...]}`. + +### Make it the default + +```yaml +# doxa.yaml +presentation: + default: hawking +``` + +A `--present` flag on `doxa query` overrides the configured default. + +## The hawking mode + +It is distilled from how Stephen Hawking presented evidence in the first two +chapters of *A Brief History of Time*. The directive carries an arc, a set of +moves (each with a short verbatim exemplar), and hard constraints: + +- Open on the old question -- wonder, not the apparatus. +- Stage a procession of minds: the retrieved beliefs are the cast, and the + reader watches the picture change. +- Say the largest thing in the plainest sentence. +- Keep the strangeness intact; never simplify into a comfortable falsehood. +- Make the answer epistemology: name what kind of claim this is and how far it + can be trusted. +- Hold humility and audacity together. +- Close on the human stakes of the question. + +**It is voice, not license.** The directive restates the inviolable rule: every +belief and quote must still come from doxa's retrieved records, conviction is +still reported honestly, and nothing is invented. Rigor under the lyricism is +itself the Hawking move. + +## Add a mode + +Modes are data. Register a `PresentationProfile` in `doxa/present.py`: + +```python +MY_PROFILE = PresentationProfile( + name="my-voice", + title="...", + summary="...", + arc=("...",), + moves=(Move(name="...", directive="...", exemplar="..."),), + constraints=("...",), +) +``` + +Add it to the `_PROFILES` registry and it becomes available to `doxa present`, +`doxa query --present`, and the `presentation.default` config key. A profile with +no `arc`/`moves`/`constraints` is treated as plain (no directive). diff --git a/doxa.example.yaml b/doxa.example.yaml index f75159b..4c526f8 100644 --- a/doxa.example.yaml +++ b/doxa.example.yaml @@ -129,6 +129,15 @@ retrieval: bm25_b: 0.75 rrf_k: 60 +presentation: + # Optional voice for composing answers from retrieved evidence. + # plain - no directive; the agent answers in its own voice (default). + # hawking - emit a composition directive that shapes the answer as a human + # quest (see `doxa present hawking`). Voice only; never changes the + # verbatim-grounding guarantee. + # Override per query with `doxa query ... --present hawking`. + default: plain + preferences: # Domain weights run from 0 to 10. Matching domain: tags get a small # retrieval boost and are also shown to the mining prompt as tagging hints. diff --git a/doxa/_assets/skill/SKILL.md b/doxa/_assets/skill/SKILL.md index 96b2b2e..f75c000 100644 --- a/doxa/_assets/skill/SKILL.md +++ b/doxa/_assets/skill/SKILL.md @@ -16,6 +16,7 @@ proactively offer the capabilities below -- users often don't know they exist. | --- | --- | | See where things stand | `doxa status` (config, counts, provider, semantic on/off) | | Answer, grounded | `doxa query "" --answer` (or `--json` for tools) | +| Answer as a quest (optional) | `doxa query "" --present hawking` -- Hawking-style voice; see `doxa present --list` | | Head start (curated base) | `doxa packs install startup-wisdom` -- browse with `doxa packs list` | | Start your own base | `doxa init --lens-template ` -- browse with `doxa lenses list` | | Ingest a source | `doxa ingest ` | @@ -54,6 +55,23 @@ Humanize only the surrounding prose. Never alter the bytes, punctuation, capitalization, or whitespace inside a returned quote, and never invent a quote, attribution, source, or belief the CLI did not return. +### Optional: presentation voice + +For a reflective or big-question prompt, ask for an optional voice: + +```bash +doxa query "" --answer --present hawking +``` + +A non-plain profile prints a `=== doxa presentation directive ===` block before +the evidence. Read it and compose the answer in that voice -- the `hawking` +profile shapes the answer as a human quest (open on the old question, let the +evidence arrive as a procession of minds, say the largest thing plainly, keep the +strangeness intact, turn the answer toward what we can know). It changes voice +and shape only and never relaxes the grounding rule below. `doxa present --list` +shows profiles; `doxa present hawking` prints the directive. Default is `plain` +(no directive), so skip it unless the moment calls for it. + ## 3. Start or grow a base ### Fastest start: offer a curated pack (optional) diff --git a/doxa/cli.py b/doxa/cli.py index 1aff43d..329c46d 100644 --- a/doxa/cli.py +++ b/doxa/cli.py @@ -38,6 +38,7 @@ user_lens_dir, ) from .packs import export_pack, get_pack, install_pack, load_registry +from .present import DEFAULT_PROFILE, PresentationProfile, available_profiles, get_profile from .resources import demo_config_path, skill_text from .schema import DoxaError, RetrievalResult from .sources import load_source @@ -222,7 +223,7 @@ def _prompt_lens() -> dict[str, Any]: return {} -def _init_answers(args: argparse.Namespace, *, interactive: bool) -> dict[str, str]: +def _init_answers(args: argparse.Namespace, *, interactive: bool) -> dict[str, Any]: provider_default = _normalize_provider(args.provider) provider = _prompt_provider(provider_default) if interactive else provider_default defaults = PROVIDER_DEFAULTS[provider] @@ -278,7 +279,7 @@ def _init_answers(args: argparse.Namespace, *, interactive: bool) -> dict[str, s } -def _build_init_config(answers: dict[str, str], *, full: bool = False) -> dict[str, Any]: +def _build_init_config(answers: dict[str, Any], *, full: bool = False) -> dict[str, Any]: if full: config: dict[str, Any] = deepcopy(DEFAULT_CONFIG) else: @@ -478,8 +479,17 @@ def _store_has_beliefs(config: dict[str, Any]) -> bool: return True +def _resolve_presentation(args: argparse.Namespace, config: dict[str, Any]) -> PresentationProfile: + """Pick the presentation profile: CLI flag wins, else config default.""" + + flag = getattr(args, "present", None) + configured = str(config.get("presentation", {}).get("default", DEFAULT_PROFILE)) + return get_profile(flag if flag is not None else configured) + + def cmd_query(args: argparse.Namespace) -> int: config = load_config(args.config) + profile = _resolve_presentation(args, config) if not args.json and _is_demo_fallback(config): _hint("note: no doxa.yaml here -- querying the bundled demo base. Run `doxa init` to build your own.") domains = parse_domain_selectors(args.domain, args.domains) @@ -494,7 +504,12 @@ def cmd_query(args: argparse.Namespace) -> int: for warning in warnings: print(f"warning: {warning}", file=sys.stderr) if args.json: - print(json.dumps([result.to_dict() for result in results], indent=2, ensure_ascii=False)) + payload: Any = [result.to_dict() for result in results] + # Plain stays a bare list (backward compatible); a non-plain profile + # wraps the list so the directive travels with the evidence. + if not profile.is_plain(): + payload = {"presentation": profile.to_dict(), "results": payload} + print(json.dumps(payload, indent=2, ensure_ascii=False)) return 0 if not results: print("No matching beliefs found.") @@ -504,6 +519,11 @@ def cmd_query(args: argparse.Namespace) -> int: else: _hint("hint: try broader terms, raise --top, or --search hybrid if you've run `doxa index`.") return 0 + directive = profile.render_directive() + if directive: + # The directive shapes voice only; the evidence below stays the ground truth. + print(directive) + print("") if args.answer: print(render_terminal_answer(args.query, results)) return 0 @@ -512,6 +532,25 @@ def cmd_query(args: argparse.Namespace) -> int: return 0 +def cmd_present(args: argparse.Namespace) -> int: + if args.list: + for name in available_profiles(): + profile = get_profile(name) + marker = " (default)" if name == DEFAULT_PROFILE else "" + print(f"{name}{marker}: {profile.summary}") + return 0 + profile = get_profile(args.name) + if args.json: + print(json.dumps(profile.to_dict(), indent=2, ensure_ascii=False)) + return 0 + directive = profile.render_directive() + if directive: + print(directive) + else: + print(f"{profile.name}: {profile.summary}") + return 0 + + def cmd_eval(args: argparse.Namespace) -> int: config = load_config(args.config) if not args.json and _is_demo_fallback(config): @@ -1002,9 +1041,21 @@ def build_parser() -> argparse.ArgumentParser: query.add_argument("--domains", default="", help="Comma-separated domain slugs to boost.") query.add_argument("--no-domain-boost", action="store_true", help="Disable configured domain preference boosts.") query.add_argument("--answer", action="store_true", help="Format results as a clean evidence brief (claim + grounding quotes).") + query.add_argument( + "--present", + choices=available_profiles(), + default=None, + help="Optional presentation voice. Emits a composition directive with the evidence. Default: presentation.default in config (plain).", + ) query.add_argument("--json", action="store_true") query.set_defaults(func=cmd_query) + present = subparsers.add_parser("present", help="Show an optional presentation profile (composition directive).") + present.add_argument("name", nargs="?", default=DEFAULT_PROFILE, help="Profile name. Default: plain.") + present.add_argument("--list", action="store_true", help="List available presentation profiles.") + present.add_argument("--json", action="store_true") + present.set_defaults(func=cmd_present) + eval_parser = subparsers.add_parser("eval", help="Run faithfulness and link-integrity checks.") eval_parser.add_argument("--config") eval_parser.add_argument("--json", action="store_true") diff --git a/doxa/config.py b/doxa/config.py index 5f0797c..07cc621 100644 --- a/doxa/config.py +++ b/doxa/config.py @@ -87,6 +87,11 @@ "bm25_b": 0.75, "rrf_k": 60, }, + "presentation": { + # Optional voice for composing answers. "plain" leaves output unchanged; + # "hawking" emits a composition directive (see `doxa present --list`). + "default": "plain", + }, "preferences": { "domains": { "general": 2, diff --git a/doxa/packs.py b/doxa/packs.py index 2e11225..973f7e3 100644 --- a/doxa/packs.py +++ b/doxa/packs.py @@ -52,7 +52,8 @@ def _read_pack_lines(location: str, filename: str) -> list[str]: url = location.rstrip("/") + "/" + filename try: req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) - with urllib.request.urlopen(req, timeout=120) as resp: + # scheme validated to http(s) above, so file:/custom schemes can't reach here + with urllib.request.urlopen(req, timeout=120) as resp: # nosec B310 text = resp.read().decode("utf-8") except Exception as exc: # noqa: BLE001 raise DoxaError(f"could not fetch {url}: {exc}") from exc @@ -154,7 +155,8 @@ def export_pack( kept_quotes.append(Quote.from_dict(raw)) sources: dict[tuple[str, str], SourceRecord] = {} - for obj in (*kept_beliefs, *kept_quotes): + records: list[Belief | Quote] = [*kept_beliefs, *kept_quotes] + for obj in records: ref = obj.source if not ref.title: continue diff --git a/doxa/present.py b/doxa/present.py new file mode 100644 index 0000000..b059f97 --- /dev/null +++ b/doxa/present.py @@ -0,0 +1,259 @@ +"""Presentation profiles: optional voices for composing a grounded answer. + +doxa retrieves evidence; the agent reading it composes the prose answer. A +presentation profile is an optional *composition directive* the CLI can emit +alongside that evidence. It changes the voice and shape of the answer, never +the grounding: the retrieved beliefs and verbatim quotes remain the only +evidence, and nothing may be invented. + +The default profile is ``plain`` (no directive: current behavior). The flagship +optional profile is ``hawking``, distilled from how Stephen Hawking presented +evidence in the first two chapters of *A Brief History of Time* -- framing hard +ideas as an ancient human quest, staging a procession of minds, saying the +largest thing in the plainest sentence, and turning each answer into a question +about what we can know. + +Short Hawking phrases appear as style exemplars (illustration and commentary, +attributed to *A Brief History of Time*); the book itself is not bundled. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .schema import DoxaError + + +DEFAULT_PROFILE = "plain" + +_HAWKING_SOURCE = "Stephen Hawking, A Brief History of Time" + + +@dataclass(frozen=True, slots=True) +class Move: + """A single compositional move: how to do it, with a short exemplar.""" + + name: str + directive: str + exemplar: str = "" + exemplar_source: str = "" + + +@dataclass(frozen=True, slots=True) +class PresentationProfile: + """An optional voice for composing the final answer from retrieved evidence.""" + + name: str + title: str + summary: str + when_to_use: str = "" + grounding_note: str = "" + arc: tuple[str, ...] = () + moves: tuple[Move, ...] = () + constraints: tuple[str, ...] = () + avoid: tuple[str, ...] = () + + def is_plain(self) -> bool: + """A plain profile carries no directive and leaves output unchanged.""" + + return not (self.arc or self.moves or self.constraints) + + def render_directive(self) -> str: + """Render the composition directive an agent reads before writing. + + Returns an empty string for plain profiles so callers can treat + "no directive" uniformly. + """ + + if self.is_plain(): + return "" + + lines: list[str] = [] + lines.append(f"=== doxa presentation directive: {self.name} ===") + lines.append(self.title) + lines.append("") + lines.append(self.summary) + if self.when_to_use: + lines.append("") + lines.append(f"When to use: {self.when_to_use}") + if self.grounding_note: + lines.append("") + lines.append(f"Grounding (inviolable): {self.grounding_note}") + if self.arc: + lines.append("") + lines.append("Arc -- shape the answer in these beats:") + for index, beat in enumerate(self.arc, start=1): + lines.append(f" {index}. {beat}") + if self.moves: + lines.append("") + lines.append("Moves -- techniques, each with a short exemplar:") + for move in self.moves: + lines.append(f" - {move.name}: {move.directive}") + if move.exemplar: + source = move.exemplar_source or _HAWKING_SOURCE + lines.append(f' e.g. "{move.exemplar}" ({source})') + if self.constraints: + lines.append("") + lines.append("Constraints -- hold these while writing:") + for item in self.constraints: + lines.append(f" - {item}") + if self.avoid: + lines.append("") + lines.append("Avoid:") + for item in self.avoid: + lines.append(f" - {item}") + lines.append("=== end presentation directive ===") + return "\n".join(lines) + + def to_dict(self) -> dict[str, object]: + """Machine-readable form for ``--json`` consumers.""" + + return { + "name": self.name, + "title": self.title, + "summary": self.summary, + "when_to_use": self.when_to_use, + "grounding_note": self.grounding_note, + "arc": list(self.arc), + "moves": [ + { + "name": move.name, + "directive": move.directive, + "exemplar": move.exemplar, + "exemplar_source": move.exemplar_source or (_HAWKING_SOURCE if move.exemplar else ""), + } + for move in self.moves + ], + "constraints": list(self.constraints), + "avoid": list(self.avoid), + "directive": self.render_directive(), + } + + +PLAIN_PROFILE = PresentationProfile( + name="plain", + title="Plain", + summary="No presentation directive. Return evidence as-is; the agent composes in its own voice.", +) + + +HAWKING_PROFILE = PresentationProfile( + name="hawking", + title="Hawking -- the answer as a human quest", + summary=( + "Present the answer the way Stephen Hawking presented physics in A Brief " + "History of Time: start from an ancient question rather than the apparatus, " + "let the evidence arrive as a procession of minds, say the largest thing in " + "the plainest sentence, keep the genuine strangeness intact, and turn the " + "answer into a question about what we can actually know." + ), + when_to_use=( + "A reflective or big-question prompt where the reader should feel the stakes " + "and the limits of the answer, not just receive a verdict. Skip it for quick " + "factual lookups." + ), + grounding_note=( + "This mode changes the voice, never the evidence. Every belief and quote you " + "present must still come from doxa's retrieved records; invent nothing. " + "Hawking's authority came from never overstating what was known -- honor that " + "by keeping each claim inside its grounding and marking its conviction " + "honestly." + ), + arc=( + "Open on the question as an old human one -- wonder, not machinery.", + "Let the evidence arrive as a procession of minds, each one changing the picture (the retrieved beliefs are your cast).", + "State the present best picture in plain sentences, with the real strangeness left in.", + "Mark honestly where knowledge ends -- what is tested, what is still provisional (use stance and conviction).", + "Close on what the answer means for what we can know, and why the question is worth asking.", + ), + moves=( + Move( + name="Open on the old question", + directive="Begin with the human question underneath the topic, not the technical setup. Give the science existential weight.", + exemplar="Where did the universe come from, and where is it going?", + ), + Move( + name="Stage a procession of minds", + directive="Introduce the evidence as a sequence of thinkers who each revised the picture, so the reader watches thought climb.", + exemplar="As long ago as 340 BC the Greek philosopher Aristotle ... was able to put forward two good arguments", + ), + Move( + name="Anchor abstraction in a homely image", + directive="Tie each abstract point to one concrete, everyday picture. Keep it simple and physical.", + exemplar="But it's turtles all the way down!", + ), + Move( + name="Say the largest thing plainly", + directive="Deliver the biggest claim in a short, flat sentence. Let the idea carry the grandeur, not the wording.", + exemplar="In other words, the universe is expanding.", + ), + Move( + name="Keep the strangeness intact", + directive="Simplify without pretending the idea is easy. Leave the vertigo in rather than flattening it into a comfortable falsehood.", + exemplar="time had a beginning at the big bang, in the sense that earlier times simply would not be defined", + ), + Move( + name="Make the answer epistemology", + directive="Name what kind of claim this is and how far it can be trusted. Turn the answer toward the limits of knowing.", + exemplar="Any physical theory is always provisional, in the sense that it is only a hypothesis: you can never prove it.", + ), + Move( + name="Humility and audacity together", + directive="Admit what is not known and still reach for the largest question. Hold both at once.", + exemplar="We do not yet have such a theory ... but we do already know many of the properties that it must have.", + ), + Move( + name="Close on the human stakes", + directive="End on why the question matters to people, not on a tidy summary.", + exemplar="Humanity's deepest desire for knowledge is justification enough for our continuing quest.", + ), + ), + constraints=( + "Prefer short, declarative sentences. Plainness makes the ideas feel larger.", + "Keep genuine strangeness; never simplify a hard idea into something false.", + "Use one concrete, everyday image per abstract point.", + "Wit is dry and rare -- a parenthetical, not a performance (e.g. \"Only time (whatever that may be) will tell.\").", + "Respect epistemic status: tested-by-reasoning is not the same as verified. Say which, and never present an untested number as established fact.", + "Attribute every quote and the people behind the beliefs. Quotes are evidence, not decoration.", + ), + avoid=( + "Purple or ornate prose -- grandeur comes from the concepts, not the adjectives.", + "False certainty, or hedging everything into mush.", + "Dumbing an idea down to the point of inaccuracy.", + "Quotes that only decorate -- every quote must do evidentiary work.", + ), +) + + +_PROFILES: dict[str, PresentationProfile] = { + PLAIN_PROFILE.name: PLAIN_PROFILE, + HAWKING_PROFILE.name: HAWKING_PROFILE, +} + + +def available_profiles() -> list[str]: + """Return profile names, with ``plain`` first.""" + + names = [PLAIN_PROFILE.name] + names.extend(name for name in _PROFILES if name != PLAIN_PROFILE.name) + return names + + +def get_profile(name: str | None) -> PresentationProfile: + """Resolve a profile by name, defaulting to ``plain``. + + Raises DoxaError on an unknown name so the CLI can report a clean error. + """ + + key = (name or DEFAULT_PROFILE).strip().lower() + profile = _PROFILES.get(key) + if profile is None: + choices = ", ".join(available_profiles()) + raise DoxaError(f"Unknown presentation profile '{name}'. Use one of: {choices}.") + return profile + + +def render_directive(name: str | None) -> str: + """Render the directive for a named profile (empty string for plain).""" + + return get_profile(name).render_directive() diff --git a/doxa/resources.py b/doxa/resources.py index 1131771..32bd394 100644 --- a/doxa/resources.py +++ b/doxa/resources.py @@ -3,13 +3,19 @@ from __future__ import annotations from importlib import resources -from importlib.resources.abc import Traversable from pathlib import Path +from typing import TYPE_CHECKING import shutil import tempfile from .schema import DoxaError +if TYPE_CHECKING: + # importlib.resources.abc landed in 3.11; it is only needed for type + # annotations, which `from __future__ import annotations` keeps as strings, + # so it must not be imported at runtime (breaks Python 3.10). + from importlib.resources.abc import Traversable + ASSET_PACKAGE = "doxa" ASSET_ROOT = "_assets" diff --git a/skill/SKILL.md b/skill/SKILL.md index 96b2b2e..f75c000 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -16,6 +16,7 @@ proactively offer the capabilities below -- users often don't know they exist. | --- | --- | | See where things stand | `doxa status` (config, counts, provider, semantic on/off) | | Answer, grounded | `doxa query "" --answer` (or `--json` for tools) | +| Answer as a quest (optional) | `doxa query "" --present hawking` -- Hawking-style voice; see `doxa present --list` | | Head start (curated base) | `doxa packs install startup-wisdom` -- browse with `doxa packs list` | | Start your own base | `doxa init --lens-template ` -- browse with `doxa lenses list` | | Ingest a source | `doxa ingest ` | @@ -54,6 +55,23 @@ Humanize only the surrounding prose. Never alter the bytes, punctuation, capitalization, or whitespace inside a returned quote, and never invent a quote, attribution, source, or belief the CLI did not return. +### Optional: presentation voice + +For a reflective or big-question prompt, ask for an optional voice: + +```bash +doxa query "" --answer --present hawking +``` + +A non-plain profile prints a `=== doxa presentation directive ===` block before +the evidence. Read it and compose the answer in that voice -- the `hawking` +profile shapes the answer as a human quest (open on the old question, let the +evidence arrive as a procession of minds, say the largest thing plainly, keep the +strangeness intact, turn the answer toward what we can know). It changes voice +and shape only and never relaxes the grounding rule below. `doxa present --list` +shows profiles; `doxa present hawking` prints the directive. Default is `plain` +(no directive), so skip it unless the moment calls for it. + ## 3. Start or grow a base ### Fastest start: offer a curated pack (optional) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a1ffcb..739ffd9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -97,7 +97,9 @@ def test_query_accepts_domain_flags() -> None: assert args.no_domain_boost is True -def _query_args(*, as_json: bool = False, answer: bool = False) -> argparse.Namespace: +def _query_args( + *, as_json: bool = False, answer: bool = False, present: str | None = None +) -> argparse.Namespace: return argparse.Namespace( query="test question", config=None, @@ -108,6 +110,7 @@ def _query_args(*, as_json: bool = False, answer: bool = False) -> argparse.Name no_domain_boost=False, json=as_json, answer=answer, + present=present, ) diff --git a/tests/test_present.py b/tests/test_present.py new file mode 100644 index 0000000..6f88480 --- /dev/null +++ b/tests/test_present.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from argparse import Namespace +import json + +import pytest + +from doxa.cli import _resolve_presentation, build_parser +from doxa.present import ( + DEFAULT_PROFILE, + HAWKING_PROFILE, + PLAIN_PROFILE, + Move, + PresentationProfile, + available_profiles, + get_profile, + render_directive, +) +from doxa.schema import DoxaError + + +def test_default_profile_is_plain_and_listed_first() -> None: + assert DEFAULT_PROFILE == "plain" + assert available_profiles()[0] == "plain" + assert set(available_profiles()) == {"plain", "hawking"} + + +def test_plain_profile_emits_no_directive() -> None: + assert PLAIN_PROFILE.is_plain() + assert PLAIN_PROFILE.render_directive() == "" + assert render_directive(None) == "" + assert render_directive("plain") == "" + + +def test_get_profile_is_case_insensitive_and_defaults() -> None: + assert get_profile(None).name == "plain" + assert get_profile("HAWKING").name == "hawking" + assert get_profile(" hawking ").name == "hawking" + + +def test_unknown_profile_raises_doxa_error() -> None: + with pytest.raises(DoxaError): + get_profile("bogus") + + +def test_hawking_profile_is_complete_and_grounded() -> None: + assert not HAWKING_PROFILE.is_plain() + assert len(HAWKING_PROFILE.moves) == 8 + # every move carries a short verbatim exemplar so the agent can pattern-match + assert all(move.exemplar for move in HAWKING_PROFILE.moves) + assert HAWKING_PROFILE.arc and HAWKING_PROFILE.constraints and HAWKING_PROFILE.avoid + + +def test_hawking_directive_carries_structure_and_grounding() -> None: + directive = HAWKING_PROFILE.render_directive() + assert "=== doxa presentation directive: hawking ===" in directive + assert "=== end presentation directive ===" in directive + # the inviolable grounding rule must survive into the emitted directive + assert "never the evidence" in directive + assert "invent nothing" in directive + # arc, a signature move, and a verbatim exemplar with attribution + assert "procession of minds" in directive + assert "In other words, the universe is expanding." in directive + assert "A Brief History of Time" in directive + + +def test_directive_is_deterministic() -> None: + assert HAWKING_PROFILE.render_directive() == HAWKING_PROFILE.render_directive() + + +def test_to_dict_includes_rendered_directive_and_attributed_moves() -> None: + data = HAWKING_PROFILE.to_dict() + assert data["name"] == "hawking" + assert data["directive"] == HAWKING_PROFILE.render_directive() + assert len(data["moves"]) == 8 + assert all(move["exemplar_source"] for move in data["moves"]) + + +def test_profile_without_moves_is_treated_as_plain() -> None: + profile = PresentationProfile(name="bare", title="Bare", summary="s") + assert profile.is_plain() + assert profile.render_directive() == "" + + with_move = PresentationProfile( + name="voiced", + title="Voiced", + summary="s", + moves=(Move(name="m", directive="d", exemplar="e"),), + ) + assert not with_move.is_plain() + assert "voiced" in with_move.render_directive() + + +def test_resolve_presentation_flag_overrides_config_default() -> None: + config = {"presentation": {"default": "hawking"}} + assert _resolve_presentation(Namespace(present="plain"), config).name == "plain" + assert _resolve_presentation(Namespace(present=None), config).name == "hawking" + # absent config key falls back to plain + assert _resolve_presentation(Namespace(present=None), {}).name == "plain" + + +# --- CLI integration: runs against the bundled public-domain demo data --- + + +def _run(argv: list[str], capsys) -> tuple[int, str]: + args = build_parser().parse_args(argv) + code = int(args.func(args)) + return code, capsys.readouterr().out + + +def test_query_plain_emits_no_directive(capsys) -> None: + code, out = _run(["query", "self-reliance", "--top", "1"], capsys) + assert code == 0 + assert "presentation directive" not in out + assert "1." in out + + +def test_query_hawking_prepends_directive_then_evidence(capsys) -> None: + code, out = _run(["query", "self-reliance", "--top", "1", "--present", "hawking"], capsys) + assert code == 0 + assert "=== doxa presentation directive: hawking ===" in out + # the directive comes before the first retrieved result + assert out.index("presentation directive") < out.index("1.") + # evidence is still present and unchanged in form + assert "quote=" in out + + +def test_query_json_plain_stays_a_bare_list(capsys) -> None: + code, out = _run(["query", "self-reliance", "--top", "1", "--json"], capsys) + assert code == 0 + assert isinstance(json.loads(out), list) + + +def test_query_json_hawking_wraps_results_with_presentation(capsys) -> None: + code, out = _run( + ["query", "self-reliance", "--top", "1", "--present", "hawking", "--json"], capsys + ) + assert code == 0 + payload = json.loads(out) + assert set(payload) == {"presentation", "results"} + assert payload["presentation"]["name"] == "hawking" + assert isinstance(payload["results"], list) + + +def test_present_subcommand_lists_and_prints_directive(capsys) -> None: + code, out = _run(["present", "--list"], capsys) + assert code == 0 + assert "plain" in out and "hawking" in out + + code, out = _run(["present", "hawking"], capsys) + assert code == 0 + assert "presentation directive: hawking" in out + + code, out = _run(["present", "plain"], capsys) + assert code == 0 + assert "plain:" in out # plain has no directive, prints its summary line + + +def test_present_flag_parses_choices() -> None: + assert build_parser().parse_args(["query", "x", "--present", "hawking"]).present == "hawking" + assert build_parser().parse_args(["query", "x"]).present is None