From 116938c29d33deae00fb771121ff3debc76214d8 Mon Sep 17 00:00:00 2001 From: duanyiqun Date: Thu, 18 Jun 2026 00:48:36 -0700 Subject: [PATCH] WS-D: expose scenario prompt profiles via CLI + gallery docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli: top-level --list-profiles; `run`/`query` gain --profile (alias of existing --prompt-profile) with fail-fast validation. Reuses prompt_profile.load_prompt_profiles / profile.to_agent_config — no new mechanism, prompt text unchanged. - docs/PROMPTS.md: "Scenario Profiles (Gallery)" table + CLI usage + custom profile authoring. Note: `query` validates --profile but it does not affect retrieval (query builds a read-only MemoryQueryAgent); documented in --help and docs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018Cafgnpw3v7U3f2Lt1ASxs --- docs/PROMPTS.md | 147 ++++++++++++++++++++++++++++++++++ src/cognifold/cli/__init__.py | 64 +++++++++++++++ src/cognifold/cli/query.py | 57 +++++++++++++ src/cognifold/cli/run.py | 53 ++++++++---- 4 files changed, 306 insertions(+), 15 deletions(-) diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md index 5bc3735..16e7827 100644 --- a/docs/PROMPTS.md +++ b/docs/PROMPTS.md @@ -93,6 +93,153 @@ profiles: custom.speaker: "## Speaker Attribution\nHandle speakers..." ``` +## Scenario Profiles (Gallery) + +Prompt profiles are named, ready-to-use bundles of domain + reasoning mode + +model + guidelines (+ optional custom template and section toggles). They live +in `configs/prompt_profiles.yaml` and are loaded by +`cognifold.agent.prompt_profile.load_prompt_profiles(path)`, which returns a +`dict[str, PromptProfile]` keyed by profile name. + +The shipped profiles: + +| Profile | Target scenario | Mode | When to use | Provider / model | +|------------------|--------------------------------------------------|------------|-----------------------------------------------------------------------------|-----------------------------------| +| `personal-v1` | Personal timeline (daily-life event streams) | quick | Fast ingest of personal activity logs; minimal concept creation | default (from config) | +| `wiki-v1` | Wiki / notes / long-form documents | analytical | Deep analysis of document chunks; synthesis-focused actions; no TIME nodes | default (temperature 0.4) | +| `wiki-v3-openai` | Wiki / novels via OpenAI with a strict template | analytical | OpenAI run that enforces connectivity + mandatory actions per concept | `openai:gpt-5.2-2025-12-11` | +| `wiki-v3-gemini` | Wiki / novels via Gemini with a strict template | analytical | Same strict graph-building rules on a Gemini model | `gemini-2.5-flash` | +| `wiki-v4-openai` | Wiki / novels via OpenAI (refined v3 template) | analytical | Latest OpenAI wiki template; prioritizes high-value synthesis actions | `openai:gpt-5.2-2025-12-11` | + +Notes: +- "Mode" is the `ReasoningMode` enum — `quick`, `analytical`, or + `consolidation`. The wiki profiles use the strict custom `templates.system` + override (which bypasses section composition); the personal/wiki-v1 profiles + use the default composed prompt for their domain. +- A profile's model (`model.name`) overrides the config model only when set; + otherwise the model comes from `CognifoldConfig` / `AgentConfig`. + +### Listing and using profiles from the CLI + +List every profile (name + domain + mode + model) and exit: + +```bash +cognifold --list-profiles +# point at a different profiles file: +cognifold --list-profiles --prompt-profiles path/to/profiles.yaml +``` + +Run graph-building with a profile (agent mode required — the profile configures +the graph-update LLM agent): + +```bash +# --profile is an alias for --prompt-profile +cognifold run examples/wiki/notes_timeline.json --agent --prompt-profile wiki-v1 +cognifold run data/timeline.json --agent --profile wiki-v3-openai +# custom profiles file: +cognifold run data/timeline.json --agent --profile my-profile \ + --prompt-profiles configs/my_profiles.yaml +``` + +The same `--profile` works in fast (layered) mode: + +```bash +cognifold run data/timeline.json --fast --agent --profile wiki-v1 +``` + +If an unknown profile name is given, the command prints the available profiles +and exits non-zero: + +```text +Error: Prompt profile not found: nope +Available profiles: personal-v1, wiki-v1, wiki-v4-openai, wiki-v3-openai, wiki-v3-gemini +``` + +For `query`: + +```bash +cognifold query --graph output/graph.json --profile wiki-v1 -v "key themes?" +``` + +`query` accepts `--profile` / `--prompt-profile` for parity and validates the +name (so typos fail fast and the resolved profile is shown in `--verbose` +output). **It does not change retrieval results** — `query` is read-only +retrieval over a pre-built graph, whereas profiles shape graph *building* via +`run`. Use profiles when building the graph; query the result however you like. + +### Authoring a custom profile + +A profile is one entry under the top-level `profiles:` key. All fields are +optional except an implicit identity (the YAML key becomes `profile_id`). The +full shape understood by `load_prompt_profiles`: + +```yaml +profiles: + my-profile: # -> PromptProfile.profile_id + domain: wiki # DomainConfig name (DOMAIN_REGISTRY key) + mode: analytical # ReasoningMode: quick | analytical | consolidation + model: + name: openai:gpt-5.2-2025-12-11 # optional; overrides config model + temperature: 0.3 + max_tokens: 4096 + max_exploration_steps: 3 + guidelines: # injected into {concept_guidelines}/{action_guidelines} + concept: + - Prefer updating existing concepts over duplicates + action: + - Create synthesis actions for strong recurring concepts + time: + - Create TIME nodes only for explicit dates + templates: # optional; OVERRIDES section composition entirely + system: | + You are a cognitive graph agent... + {concept_guidelines} + {action_guidelines} + user: | + Process this event: {event} + sections: # optional; only applies when NOT using templates.system + disabled: # section or group names to exclude + - intents + - time + extra: # custom sections to inject + custom.speaker: "## Speaker Attribution\n..." + features: # free-form flags consumed downstream + enable_time_nodes: false +``` + +Then load and verify: + +```bash +cognifold --list-profiles --prompt-profiles configs/my_profiles.yaml +cognifold run data/timeline.json --agent --profile my-profile \ + --prompt-profiles configs/my_profiles.yaml +``` + +### Toggling sections via DomainConfig + +Profiles inherit their domain's section composition from +`DomainConfig` (`src/cognifold/agent/domain.py`). The relevant fields: + +- `disabled_sections: frozenset[str]` — exclude individual sections + (e.g., `core.tools`) or whole groups (`"intents"`, `"time"`, `"concepts"`, + `"core"`, `"symbolic"`). Group names are expanded to their member sections. + Example: `LOCOMO_DOMAIN` uses `disabled_sections=frozenset({"intents"})` to + drop all intent sections for the benchmark. +- `extra_sections: dict[str, str]` — inject custom prompt text keyed by a + custom section name (e.g., `CLAUDE_CODE_DOMAIN`'s `claude_code.tool_context`). +- `extra_section_position: str` — where extras are injected: + `"before_rules"` (default), `"after_tools"`, or `"after_rules"`. + +A profile can override these per-run via its `sections:` block (`disabled` / +`extra` above), which feeds `PromptProfile.disabled_sections` / +`PromptProfile.extra_sections`. Section names and groups are defined in +`SECTION_REGISTRY` / `SECTION_GROUPS` and resolved by `resolve_sections()` in +`src/cognifold/agent/prompt_sections.py`. + +> Note: when a profile sets `templates.system`, section composition is bypassed +> entirely — the raw template (with `{concept_guidelines}` / `{action_guidelines}` +> placeholders) is used as-is. Use `sections:` only with the default composed prompt. + ## System Prompt Structure The system prompt includes these sections in order: diff --git a/src/cognifold/cli/__init__.py b/src/cognifold/cli/__init__.py index 526a4d4..95fb0ec 100644 --- a/src/cognifold/cli/__init__.py +++ b/src/cognifold/cli/__init__.py @@ -82,8 +82,25 @@ def main() -> int: # Version parser.add_argument("--version", action="version", version="cognifold 0.1.0") + # List available prompt profiles and exit + parser.add_argument( + "--list-profiles", + action="store_true", + help="List available prompt profiles (from configs/prompt_profiles.yaml) and exit", + ) + parser.add_argument( + "--prompt-profiles", + type=str, + default="configs/prompt_profiles.yaml", + help="Path to prompt profiles YAML used by --list-profiles " + "(default: configs/prompt_profiles.yaml)", + ) + args = parser.parse_args() + if getattr(args, "list_profiles", False): + return list_profiles_command(args.prompt_profiles) + if args.command == "run": return run_command(args) elif args.command == "query": @@ -105,5 +122,52 @@ def main() -> int: return 0 +def _profile_summary(profile: object) -> str: + """Build a one-line human description of a prompt profile. + + Pulls domain, reasoning mode, and model (when set) from the profile so the + listing is informative without dumping the full YAML. + """ + domain = getattr(profile, "domain", None) or "default" + mode = getattr(profile, "mode", None) + mode_str = mode.value if mode is not None else "default" + model = getattr(profile, "model_name", None) + parts = [f"domain={domain}", f"mode={mode_str}"] + if model: + parts.append(f"model={model}") + return ", ".join(parts) + + +def list_profiles_command(profiles_path: str) -> int: + """Print all prompt profiles found in *profiles_path* and exit. + + Uses the same loader the run command and benchmarks use + (:func:`cognifold.agent.prompt_profile.load_prompt_profiles`). + """ + from pathlib import Path + + from cognifold.agent.prompt_profile import load_prompt_profiles + + path = Path(profiles_path) + if not path.exists(): + print(f"Error: Prompt profiles file not found: {path}", file=sys.stderr) + return 1 + + profiles = load_prompt_profiles(path) + if not profiles: + print(f"No prompt profiles defined in {path}") + return 0 + + print(f"Available prompt profiles ({path}):\n") + width = max(len(name) for name in profiles) + for name, profile in profiles.items(): + print(f" {name.ljust(width)} {_profile_summary(profile)}") + print( + "\nUse with: cognifold run --agent --prompt-profile " + " [--prompt-profiles ]" + ) + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/cognifold/cli/query.py b/src/cognifold/cli/query.py index cc65a69..527169a 100644 --- a/src/cognifold/cli/query.py +++ b/src/cognifold/cli/query.py @@ -175,6 +175,26 @@ def add_query_parser(subparsers: argparse._SubParsersAction) -> None: # type: i help="Get detailed explanation of a specific node", ) + # Prompt profile (validated for parity with `run`; see note below) + parser.add_argument( + "--prompt-profile", + "--profile", + dest="prompt_profile", + type=str, + help=( + "Prompt profile ID (e.g., wiki-v1). Validated against the profiles " + "file; surfaced in --verbose output. Note: query is read-only " + "retrieval over a pre-built graph, so the profile does not change " + "retrieval results -- profiles shape graph *building* via `run`." + ), + ) + parser.add_argument( + "--prompt-profiles", + type=str, + default="configs/prompt_profiles.yaml", + help="Path to prompt profiles YAML (default: configs/prompt_profiles.yaml)", + ) + # Verbosity parser.add_argument( "--verbose", @@ -184,6 +204,31 @@ def add_query_parser(subparsers: argparse._SubParsersAction) -> None: # type: i ) +def _resolve_prompt_profile(name: str, profiles_path: str): + """Load and return the named prompt profile, or None on error. + + On an unknown profile name (or missing file) prints the available profile + names to stderr and returns None so the caller can exit non-zero. Uses the + same loader the run command and benchmarks use + (:func:`cognifold.agent.prompt_profile.load_prompt_profiles`). + """ + from cognifold.agent.prompt_profile import load_prompt_profiles + + path = Path(profiles_path) + if not path.exists(): + print(f"Error: Prompt profiles file not found: {path}", file=sys.stderr) + return None + + profiles = load_prompt_profiles(path) + profile = profiles.get(name) + if profile is None: + available = ", ".join(profiles) if profiles else "(none defined)" + print(f"Error: Prompt profile not found: {name}", file=sys.stderr) + print(f"Available profiles: {available}", file=sys.stderr) + return None + return profile + + def _create_embedder(args: argparse.Namespace): """Create an embedder for semantic search if possible. @@ -247,6 +292,13 @@ def query_command(args: argparse.Namespace) -> int: print(f"Error loading graph: {e}", file=sys.stderr) return 1 + # Validate prompt profile if provided (fails fast on typos, parity with `run`) + prompt_profile = None + if getattr(args, "prompt_profile", None): + prompt_profile = _resolve_prompt_profile(args.prompt_profile, args.prompt_profiles) + if prompt_profile is None: + return 1 + # Map retrieval mode retrieval_mode_map = { "legacy": RetrievalMode.LEGACY, @@ -317,6 +369,11 @@ def query_command(args: argparse.Namespace) -> int: print(f"Query: {args.query}") print(f"Type: {args.type}") print(f"Retrieval: {args.retrieval}") + if prompt_profile is not None: + print( + f"Prompt profile: {prompt_profile.profile_id} " + "(validated; does not affect retrieval)" + ) print() result = agent.query( diff --git a/src/cognifold/cli/run.py b/src/cognifold/cli/run.py index 6f8ad31..01895ac 100644 --- a/src/cognifold/cli/run.py +++ b/src/cognifold/cli/run.py @@ -8,6 +8,34 @@ from typing import Any +def _resolve_prompt_profile(name: str, profiles_path: str, logger: Any) -> Any | None: + """Load and return the named prompt profile, or None on error. + + On an unknown profile name (or missing file) this logs/prints the available + profile names and returns None so the caller can exit non-zero. Uses the + same loader the service and benchmarks use + (:func:`cognifold.agent.prompt_profile.load_prompt_profiles`). + """ + from cognifold.agent.prompt_profile import load_prompt_profiles + + path = Path(profiles_path) + if not path.exists(): + msg = f"Prompt profiles file not found: {path}" + logger.error(msg) + print(f"Error: {msg}") + return None + + profiles = load_prompt_profiles(path) + profile = profiles.get(name) + if profile is None: + available = ", ".join(profiles) if profiles else "(none defined)" + logger.error(f"Prompt profile not found: {name}") + print(f"Error: Prompt profile not found: {name}") + print(f"Available profiles: {available}") + return None + return profile + + def add_run_parser(subparsers: argparse._SubParsersAction) -> None: # type: ignore """Add the run subcommand parser.""" run_parser = subparsers.add_parser("run", help="Run simulation on a timeline") @@ -31,6 +59,8 @@ def add_run_parser(subparsers: argparse._SubParsersAction) -> None: # type: ign ) run_parser.add_argument( "--prompt-profile", + "--profile", + dest="prompt_profile", type=str, help="Prompt profile ID for agent mode (e.g., wiki-v1, personal-v1)", ) @@ -161,7 +191,6 @@ def run_command(args: argparse.Namespace) -> int: prompt_profile = None if args.agent: from cognifold.agent import AgentConfig - from cognifold.agent.prompt_profile import load_prompt_profiles base_agent_config = AgentConfig( model_name=config.model.name, @@ -171,13 +200,10 @@ def run_command(args: argparse.Namespace) -> int: ) if args.prompt_profile: - profiles_path = Path(args.prompt_profiles) - if profiles_path.exists(): - profiles = load_prompt_profiles(profiles_path) - prompt_profile = profiles.get(args.prompt_profile) - if not prompt_profile: - logger.error(f"Prompt profile not found: {args.prompt_profile}") - print(f"Error: Prompt profile not found: {args.prompt_profile}") + prompt_profile = _resolve_prompt_profile( + args.prompt_profile, args.prompt_profiles, logger + ) + if prompt_profile is None: return 1 agent_config = prompt_profile.to_agent_config(base_agent_config) else: @@ -404,7 +430,6 @@ def _run_fast_mode( prompt_profile: Any = None if args.agent: from cognifold.agent import AgentConfig - from cognifold.agent.prompt_profile import load_prompt_profiles base_agent_config = AgentConfig( model_name=config.model.name, @@ -414,12 +439,10 @@ def _run_fast_mode( ) if getattr(args, "prompt_profile", None): - profiles_path = Path(args.prompt_profiles) - if profiles_path.exists(): - profiles = load_prompt_profiles(profiles_path) - prompt_profile = profiles.get(args.prompt_profile) - if not prompt_profile: - print(f"Error: Prompt profile not found: {args.prompt_profile}") + prompt_profile = _resolve_prompt_profile( + args.prompt_profile, args.prompt_profiles, logger + ) + if prompt_profile is None: return 1 agent_config = prompt_profile.to_agent_config(base_agent_config) else: