Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,13 @@ parallel-cli
│ ├── list # List available Parallel skills
│ ├── reinstall # Cleanly reinstall managed Parallel skills
│ └── uninstall # Remove only CLI-managed Parallel skills
└── memory # Saved Task, Monitor, and FindAll entries
├── retrieve # Search Memory or list recent entries
├── evict # Remove one entry from Memory
└── clear # Permanently clear selected Memory
├── memory # Saved Task, Monitor, and FindAll entries
│ ├── retrieve # Search Memory or list recent entries
│ ├── evict # Remove one entry from Memory
│ └── clear # Permanently clear selected Memory
└── feedback # Report problems to the Parallel team
├── send # Preview a report, then send it with consent
└── consent # Show or set "don't ask again"
```

Run `parallel-cli skills install` to install or update agent skills. When using
Expand Down Expand Up @@ -239,6 +242,30 @@ Omit `--scope-key` / `--memory-scope-key` to use personal Memory when the
credential and account are eligible. Application credentials require a stable
scope key containing only letters, digits, underscores, or hyphens.

### 7. Report a Problem to Parallel

Report significant problems, such as wrong results for specific runs, with the
request IDs attached. Nothing is sent until the user agrees: the CLI shows
exactly what Parallel would receive and asks first.

```bash
parallel-cli feedback send \
-m "Eval runs return the wrong CEO for most companies." \
--category results_quality --product task --severity blocking \
--example "trun_abc123:Returned John Roe; expected Jane Doe"

# Agents: --json prints the preview without sending. Show it to the user, then
# rerun with their answer and the preview's preview_id.
parallel-cli feedback send ... --json
parallel-cli feedback send ... --consent this_report --preview-id <preview_id> --json

# Show or reset "don't ask again" (personal credentials only)
parallel-cli feedback consent
parallel-cli feedback consent ask
```

Feedback is not available to organizations on zero or partial data retention.

## Non-Interactive Mode (for AI Agents & Scripts)

All commands support `--json` output and can be fully controlled via CLI arguments.
Expand Down
194 changes: 194 additions & 0 deletions parallel_web_tools/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@
update_monitor,
validate_memory_scope_key,
)
from parallel_web_tools.core.feedback import (
CONSENT_PREFERENCES,
FEEDBACK_CATEGORIES,
FEEDBACK_PRODUCTS,
FEEDBACK_SEVERITIES,
FeedbackInputError,
get_feedback_consent,
parse_example,
send_feedback,
set_feedback_consent,
)

# Standalone CLI (PyInstaller) has limited features to reduce bundle size
# YAML config and interactive planner require: pip install parallel-web-tools[cli]
Expand Down Expand Up @@ -1084,6 +1095,189 @@ def memory_clear(memory_scope_key: str | None, confirm_clear: bool, output_json:
console.print("[dim]Underlying Task, Monitor, and FindAll resources were not deleted.[/dim]")


# =============================================================================
# Feedback Commands
# =============================================================================


@main.group()
def feedback():
"""Report significant problems with Parallel to the Parallel team."""
pass


def _example_option(_ctx: click.Context, _param: click.Parameter, values: tuple[str, ...]) -> list[dict[str, str]]:
try:
return [parse_example(value) for value in values]
except FeedbackInputError as e:
raise click.BadParameter(str(e)) from e


def _render_feedback_preview(response: dict[str, Any]) -> None:
preview = response.get("preview", {})
context = preview.get("context", {})
console.print("[bold]Parallel would receive:[/bold]")
console.print(
f" {preview.get('category')} · {preview.get('product')} · {preview.get('severity')}",
markup=False,
)
console.print(f" Message: {preview.get('message', '')}", markup=False)
for label in ("expected", "actual"):
if preview.get(label):
console.print(f" {label.capitalize()}: {preview[label]}", markup=False)
for example in preview.get("examples", []):
note = f" — {example['issue']}" if example.get("issue") else ""
console.print(f" • {example.get('id')}{note}", markup=False)
console.print(f" From: {context.get('sender', 'unknown')}", markup=False)
if preview.get("redactions"):
console.print(f" [dim]{preview['redactions']} secret(s) removed.[/dim]")
if preview.get("recipient"):
console.print(f" {preview['recipient']}", markup=False, style="dim")


def _render_feedback_result(response: dict[str, Any]) -> None:
if response.get("duplicate"):
console.print(f"[yellow]Parallel already received this report as {response.get('feedback_id')}.[/yellow]")
else:
console.print(f"[bold green]Sent to Parallel as {response.get('feedback_id')}.[/bold green]")
if response.get("consent_preference") == "always":
console.print("[dim]Later reports are sent without asking. Reset with: parallel-cli feedback consent ask[/dim]")


def _ask_feedback_consent(response: dict[str, Any]) -> str | None:
"""Ask whether to send the previewed report; returns the consent, or None to stop."""
can_remember = response.get("preview", {}).get("remember_scope") is not None
choices = ["s", "a", "n"] if can_remember else ["s", "n"]
console.print(
"Send it? [bold]s[/bold]end"
+ (", send and don't ask [bold]a[/bold]gain" if can_remember else "")
+ ", or [bold]n[/bold]o"
)
answer = click.prompt("", type=click.Choice(choices), show_choices=False, prompt_suffix="> ")
return {"s": "this_report", "a": "always"}.get(answer)


@feedback.command(name="send")
@click.option("-m", "--message", required=True, help="What you were doing, what went wrong, and what you expected.")
@click.option("--category", type=click.Choice(list(FEEDBACK_CATEGORIES)), required=True, help="The kind of problem.")
@click.option(
"--product", type=click.Choice(list(FEEDBACK_PRODUCTS)), required=True, help="The Parallel product involved."
)
@click.option(
"--severity",
type=click.Choice(list(FEEDBACK_SEVERITIES)),
default="degraded",
show_default=True,
help="blocking, degraded (a workaround was needed) or minor (noticeable, but the work went on unchanged).",
)
@click.option(
"--example",
"examples",
multiple=True,
callback=_example_option,
help="A request ID, optionally with a note: 'trun_abc:Returned the wrong CEO'. Repeatable.",
)
@click.option("--expected", help="The overall expected behavior.")
@click.option("--actual", help="The overall observed behavior.")
@click.option(
"--consent",
type=click.Choice(["this_report", "always"]),
help="The user approved sending this report (this_report) and, with always, later ones.",
)
@click.option("--preview-id", help="The preview_id of the preview the user approved.")
@click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout; never prompts.")
def feedback_send(
message: str,
category: str,
product: str,
severity: str,
examples: list[dict[str, str]],
expected: str | None,
actual: str | None,
consent: str | None,
preview_id: str | None,
output_json: bool,
):
"""Report a significant problem, such as wrong results for specific runs.

Nothing is sent until the user agrees. Without --consent the CLI shows
exactly what Parallel would receive and asks; with --json it prints that
preview so an agent can ask the user, then rerun with --consent and
--preview-id.
"""

def send(user_consent: str, approved_preview: str | None) -> dict[str, Any]:
return send_feedback(
message=message,
category=category,
product=product,
severity=severity,
examples=examples,
expected=expected,
actual=actual,
user_consent=user_consent,
preview_id=approved_preview,
source="cli",
)

try:
response = send(consent or "not_asked", preview_id)
if response.get("status") == "consent_required" and not output_json:
_render_feedback_preview(response)
approved = _ask_feedback_consent(response)
if approved is None:
console.print("Nothing was sent.")
return
response = send(approved, response.get("preview_id"))
except FeedbackInputError as e:
_handle_error(e, output_json=output_json, exit_code=EXIT_BAD_INPUT, prefix="Invalid feedback")
return
except Exception as e:
_handle_error(e, output_json=output_json, prefix="Feedback API error")
return

if output_json:
print(json.dumps(response, indent=2))
elif response.get("status") == "consent_required":
_render_feedback_preview(response)
console.print(response.get("next_step", "Nothing was sent."), markup=False, style="yellow")
else:
_render_feedback_result(response)


@feedback.command(name="consent")
@click.argument("preference", required=False, type=click.Choice(list(CONSENT_PREFERENCES)))
@click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout.")
def feedback_consent(preference: str | None, output_json: bool):
"""Show or set whether feedback reports are sent without asking.

With no argument, shows the current preference. 'always' sends reports
without a preview; 'ask' previews each report first. Only personal
credentials can store a preference.
"""
try:
if preference is None:
result = get_feedback_consent(source="cli")
else:
result = set_feedback_consent(preference, source="cli")
except FeedbackInputError as e:
_handle_error(e, output_json=output_json, exit_code=EXIT_BAD_INPUT, prefix="Invalid feedback consent")
return
except Exception as e:
_handle_error(e, output_json=output_json, prefix="Feedback API error")
return

if output_json:
print(json.dumps(result, indent=2))
return
if result.get("preference") == "always":
console.print("Feedback reports are sent [bold]without asking[/bold].")
else:
console.print("Each feedback report is [bold]previewed[/bold] and needs approval.")
if result.get("scope"):
console.print(f"Applies to {result['scope']}.", markup=False, style="dim")


# =============================================================================
# Search Command
# =============================================================================
Expand Down
139 changes: 139 additions & 0 deletions parallel_web_tools/core/feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Send feedback about Parallel to the Parallel team.

The API sends nothing until the user consents: a first call returns a preview of
exactly what Parallel would receive, and the user approves it for this report
(``this_report``) or for all later ones (``always``).
"""

from __future__ import annotations

import re
from typing import Any, Literal

from parallel_web_tools.core.auth import create_client
from parallel_web_tools.core.user_agent import ClientSource

FeedbackCategory = Literal["results_quality", "error", "latency", "missing_capability", "docs", "billing", "other"]
FeedbackProduct = Literal["search", "extract", "task", "findall", "monitor", "responses", "mcp", "cli", "other"]
FeedbackSeverity = Literal["blocking", "degraded", "minor"]
UserConsent = Literal["not_asked", "this_report", "always"]
ConsentPreference = Literal["ask", "always"]

FEEDBACK_CATEGORIES: tuple[FeedbackCategory, ...] = (
"results_quality",
"error",
"latency",
"missing_capability",
"docs",
"billing",
"other",
)
FEEDBACK_PRODUCTS: tuple[FeedbackProduct, ...] = (
"search",
"extract",
"task",
"findall",
"monitor",
"responses",
"mcp",
"cli",
"other",
)
FEEDBACK_SEVERITIES: tuple[FeedbackSeverity, ...] = ("blocking", "degraded", "minor")
USER_CONSENTS: tuple[UserConsent, ...] = ("not_asked", "this_report", "always")
CONSENT_PREFERENCES: tuple[ConsentPreference, ...] = ("ask", "always")

MIN_MESSAGE_CHARS = 20
MAX_MESSAGE_CHARS = 4000
MAX_EXAMPLES = 50
MAX_NOTE_CHARS = 1000
_EXAMPLE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")

_FEEDBACK_PATH = "/v1beta/feedback"
_CONSENT_PATH = "/v1beta/feedback/consent"


class FeedbackInputError(ValueError):
"""Raised when a feedback request is invalid before it reaches the API."""


def parse_example(raw: str) -> dict[str, str]:
"""Parse ``ID`` or ``ID:note`` into an example; IDs never contain a colon."""
example_id, sep, note = raw.partition(":")
example_id = example_id.strip()
if not _EXAMPLE_ID_RE.fullmatch(example_id):
raise FeedbackInputError(
f"example {raw!r} must start with a Parallel ID such as search_..., extract_..., or trun_..."
)
note = note.strip()
if len(note) > MAX_NOTE_CHARS:
raise FeedbackInputError(f"example note must be at most {MAX_NOTE_CHARS} characters")
return {"id": example_id, "issue": note} if sep and note else {"id": example_id}


def _check_choice(value: str, choices: tuple[str, ...], field: str) -> None:
if value not in choices:
raise FeedbackInputError(f"{field} must be one of: {', '.join(choices)}")


def send_feedback(
message: str,
category: str,
product: str,
*,
severity: str = "degraded",
examples: list[dict[str, str]] | None = None,
expected: str | None = None,
actual: str | None = None,
user_consent: str = "not_asked",
preview_id: str | None = None,
api_key: str | None = None,
source: ClientSource = "python",
) -> dict[str, Any]:
"""Preview or send a feedback report.

With ``user_consent="not_asked"`` and no stored ``always`` preference, the
response has ``status="consent_required"`` and a preview; nothing is sent.
"""
if not MIN_MESSAGE_CHARS <= len(message) <= MAX_MESSAGE_CHARS:
raise FeedbackInputError(f"message must be {MIN_MESSAGE_CHARS}-{MAX_MESSAGE_CHARS} characters")
_check_choice(category, FEEDBACK_CATEGORIES, "category")
_check_choice(product, FEEDBACK_PRODUCTS, "product")
_check_choice(severity, FEEDBACK_SEVERITIES, "severity")
_check_choice(user_consent, USER_CONSENTS, "user_consent")
if examples is not None and len(examples) > MAX_EXAMPLES:
raise FeedbackInputError(f"examples must be at most {MAX_EXAMPLES}")

body: dict[str, Any] = {
"message": message,
"category": category,
"product": product,
"severity": severity,
}
if examples:
body["examples"] = examples
if expected:
body["expected"] = expected
if actual:
body["actual"] = actual
body["user_consent"] = user_consent
if preview_id:
body["preview_id"] = preview_id

client = create_client(api_key, source)
return client.post(path=_FEEDBACK_PATH, body=body, cast_to=dict)


def get_feedback_consent(*, api_key: str | None = None, source: ClientSource = "python") -> dict[str, Any]:
"""Return whether reports are sent without asking (``always``) or previewed first (``ask``)."""
client = create_client(api_key, source)
return client.get(_CONSENT_PATH, cast_to=dict)


def set_feedback_consent(
preference: str, *, api_key: str | None = None, source: ClientSource = "python"
) -> dict[str, Any]:
"""Store ``always`` or reset to ``ask``. Only personal credentials can store a preference."""
_check_choice(preference, CONSENT_PREFERENCES, "preference")
client = create_client(api_key, source)
return client.put(path=_CONSENT_PATH, body={"preference": preference}, cast_to=dict)
Loading
Loading