kiwi-code 0.0.501__tar.gz → 0.0.502__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.
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/PKG-INFO +1 -1
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/pyproject.toml +1 -1
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/client.py +62 -2
- kiwi_code-0.0.502/src/kiwi_cli/human_questions.py +200 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard.py +372 -37
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_css.py +132 -1
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_widgets.py +5 -1
- kiwi_code-0.0.502/src/kiwi_tui/screens/term_question_widgets.py +567 -0
- kiwi_code-0.0.502/tests/test_human_questions.py +636 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/uv.lock +1 -1
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.gitignore +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.python-version +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/Makefile +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/README.md +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/common/banner.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/edition.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/cli.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/handler.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/sessions.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/ui.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_helpers.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/test_hello.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/__init__.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/conftest.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_run_steering.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_imports_on_windows.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_stats_command.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_term_dashboard_ui.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_worktrees.py +0 -0
|
@@ -11,8 +11,12 @@ from pykiwiai.api.hello import hello_v1_hello_get
|
|
|
11
11
|
from pykiwiai.api.auth import (
|
|
12
12
|
return_user_and_session_v1_auth_post,
|
|
13
13
|
)
|
|
14
|
-
from pykiwiai.api.actions import
|
|
14
|
+
from pykiwiai.api.actions import (
|
|
15
|
+
answer_human_questions_v1_actions_action_result_id_answer_post,
|
|
16
|
+
async_run_action_v1_actions_id_async_run_post,
|
|
17
|
+
)
|
|
15
18
|
from pykiwiai.api.action_results import get_action_result_v1_action_results_id_get
|
|
19
|
+
from pykiwiai.models.human_answers_request import HumanAnswersRequest
|
|
16
20
|
from pykiwiai.models.body_return_user_and_session_v1_auth_post import (
|
|
17
21
|
BodyReturnUserAndSessionV1AuthPost,
|
|
18
22
|
)
|
|
@@ -21,6 +25,7 @@ from pykiwiai.models.body_async_run_action_v1_actions_id_async_run_post import (
|
|
|
21
25
|
)
|
|
22
26
|
from pykiwiai.models import DataBlock, Metadata
|
|
23
27
|
from .models import AuthTokens, LoginCredentials
|
|
28
|
+
from .human_questions import WAITING_STATUS, human_event_type, parse_questions
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class AutobotsClientWrapper:
|
|
@@ -545,6 +550,11 @@ class AutobotsClientWrapper:
|
|
|
545
550
|
except Exception:
|
|
546
551
|
pass
|
|
547
552
|
|
|
553
|
+
questions = getattr(result, "human_questions", None)
|
|
554
|
+
result_dict["human_questions"] = parse_questions(
|
|
555
|
+
[q.to_dict() for q in questions] if isinstance(questions, list) else None
|
|
556
|
+
)
|
|
557
|
+
|
|
548
558
|
result_dict["id"] = run_id
|
|
549
559
|
|
|
550
560
|
# Prompts waiting behind the current turn: with any left, a finished turn
|
|
@@ -604,6 +614,10 @@ class AutobotsClientWrapper:
|
|
|
604
614
|
elif status == "cancelled":
|
|
605
615
|
return False, result, "Action cancelled"
|
|
606
616
|
|
|
617
|
+
# The turn ended asking the user something: done until they answer.
|
|
618
|
+
elif status == WAITING_STATUS:
|
|
619
|
+
return True, result, "Action waiting for an answer"
|
|
620
|
+
|
|
607
621
|
# Still processing (a run being cancelled is not over until it says so)
|
|
608
622
|
elif status in ["processing", "running", "pending", "request_cancel"]:
|
|
609
623
|
logger.debug(f"Action still {status}, attempt {attempt + 1}/{max_attempts}")
|
|
@@ -684,7 +698,12 @@ class AutobotsClientWrapper:
|
|
|
684
698
|
try:
|
|
685
699
|
data = json.loads(data_str)
|
|
686
700
|
on_message(data)
|
|
687
|
-
|
|
701
|
+
# A question event's `status` is the questions', not the run's.
|
|
702
|
+
if (
|
|
703
|
+
stop_on_terminal_status
|
|
704
|
+
and isinstance(data, dict)
|
|
705
|
+
and human_event_type(data) is None
|
|
706
|
+
):
|
|
688
707
|
status = str(data.get("status", "") or "").lower()
|
|
689
708
|
if status in ["completed", "success", "finished", "failed", "error", "cancelled"]:
|
|
690
709
|
return True
|
|
@@ -751,6 +770,47 @@ class AutobotsClientWrapper:
|
|
|
751
770
|
logger.error(f"SSE error: {e}")
|
|
752
771
|
raise
|
|
753
772
|
|
|
773
|
+
def answer_human_questions(
|
|
774
|
+
self, run_id: str, answers: list[dict]
|
|
775
|
+
) -> tuple[int, Optional[dict], str]:
|
|
776
|
+
"""Answer questions the run's model asked with `ask_human`.
|
|
777
|
+
|
|
778
|
+
`POST /v1/actions/{run_id}/answer`. Every open question must be in `answers`, each
|
|
779
|
+
answered (`selected` and/or `text`) or skipped (`skip: true`); recorded all or nothing.
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
Tuple of (http_status, body, message). On 200 the body has `question_ids` and
|
|
783
|
+
`delivered_as`: "turn" when the run had finished and reopened as processing,
|
|
784
|
+
"instruct" when it was still running. 0 means the request did not complete.
|
|
785
|
+
"""
|
|
786
|
+
try:
|
|
787
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
788
|
+
return 401, None, "Authentication required"
|
|
789
|
+
response = answer_human_questions_v1_actions_action_result_id_answer_post.sync_detailed(
|
|
790
|
+
action_result_id=run_id,
|
|
791
|
+
client=self.client,
|
|
792
|
+
body=HumanAnswersRequest.from_dict({"answers": answers}),
|
|
793
|
+
)
|
|
794
|
+
code = int(response.status_code)
|
|
795
|
+
if code == 200 and response.parsed is not None and hasattr(response.parsed, "delivered_as"):
|
|
796
|
+
body = response.parsed.to_dict()
|
|
797
|
+
logger.info(f"Answered {len(answers)} question(s) on run {run_id}: {body}")
|
|
798
|
+
return 200, body, "Answer sent"
|
|
799
|
+
# Only 200 and 422 are typed; the 400/403/404/409 `detail` is read from the body.
|
|
800
|
+
try:
|
|
801
|
+
body = json.loads(response.content or b"null")
|
|
802
|
+
except ValueError:
|
|
803
|
+
body = None
|
|
804
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
805
|
+
if not isinstance(detail, str):
|
|
806
|
+
detail = json.dumps(detail) if detail else (response.content or b"").decode("utf-8", "replace")
|
|
807
|
+
message = f"HTTP {code}" + (f": {detail}" if detail else "")
|
|
808
|
+
logger.warning(f"Answering questions on run {run_id} failed: {message}")
|
|
809
|
+
return code, body if isinstance(body, dict) else None, message
|
|
810
|
+
except Exception as e:
|
|
811
|
+
logger.error(f"Error answering questions on run {run_id}: {e}")
|
|
812
|
+
return 0, None, f"Error sending answer: {e}"
|
|
813
|
+
|
|
754
814
|
def close(self) -> None:
|
|
755
815
|
"""Close the client connection."""
|
|
756
816
|
try:
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Questions an agent asks the user mid-run with the `ask_human` tool.
|
|
2
|
+
|
|
3
|
+
The run document is the source of truth: `human_questions` on
|
|
4
|
+
`GET /v1/action_results/{run_id}` holds every question ever asked in the run, each
|
|
5
|
+
pointing at the turn it was asked in (`turn_index`, an index into `result.results`).
|
|
6
|
+
The three events on the run's SSE topic only signal that the list changed; they are
|
|
7
|
+
delivered at most once, so a client refetches the run on each one.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
ASK_HUMAN_EVENT = "ask_human"
|
|
15
|
+
HUMAN_ANSWERED_EVENT = "human_answered"
|
|
16
|
+
HUMAN_QUESTIONS_CLOSED_EVENT = "human_questions_closed"
|
|
17
|
+
HUMAN_EVENT_TYPES = frozenset({ASK_HUMAN_EVENT, HUMAN_ANSWERED_EVENT, HUMAN_QUESTIONS_CLOSED_EVENT})
|
|
18
|
+
|
|
19
|
+
#: `result.results[i].input.source` of a turn the backend opened with the user's answers.
|
|
20
|
+
#: The cards already show the answers, so that turn's input is not drawn as a user message.
|
|
21
|
+
HUMAN_ANSWER_SOURCE = "human_answer"
|
|
22
|
+
|
|
23
|
+
#: Action-run status of a run whose turn ended with an unanswered question. The run is
|
|
24
|
+
#: finished as far as a client is concerned: stop polling and let the user type.
|
|
25
|
+
WAITING_STATUS = "waiting"
|
|
26
|
+
|
|
27
|
+
PENDING = "pending"
|
|
28
|
+
ANSWERED = "answered"
|
|
29
|
+
SKIPPED = "skipped"
|
|
30
|
+
CANCELLED = "cancelled"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def human_event_type(data: object) -> str | None:
|
|
34
|
+
"""The event type when `data` is one of the three human-question events."""
|
|
35
|
+
if isinstance(data, dict):
|
|
36
|
+
t = data.get("type")
|
|
37
|
+
if t in HUMAN_EVENT_TYPES:
|
|
38
|
+
return str(t)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _question(raw: object) -> dict[str, Any] | None:
|
|
43
|
+
if not isinstance(raw, dict) or not raw.get("question_id"):
|
|
44
|
+
return None
|
|
45
|
+
options = []
|
|
46
|
+
for opt in raw.get("options") or []:
|
|
47
|
+
if isinstance(opt, dict) and opt.get("label"):
|
|
48
|
+
options.append({"label": str(opt["label"]), "description": str(opt.get("description") or "")})
|
|
49
|
+
answer = raw.get("answer")
|
|
50
|
+
if isinstance(answer, dict):
|
|
51
|
+
answer = {
|
|
52
|
+
"selected": [str(s) for s in (answer.get("selected") or [])],
|
|
53
|
+
"text": answer.get("text"),
|
|
54
|
+
"skipped": bool(answer.get("skipped")),
|
|
55
|
+
"answered_by": answer.get("answered_by"),
|
|
56
|
+
"answered_at": answer.get("answered_at"),
|
|
57
|
+
}
|
|
58
|
+
else:
|
|
59
|
+
answer = None
|
|
60
|
+
try:
|
|
61
|
+
turn_index = int(raw.get("turn_index") or 0)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
turn_index = 0
|
|
64
|
+
return {
|
|
65
|
+
"question_id": str(raw["question_id"]),
|
|
66
|
+
"turn_index": turn_index,
|
|
67
|
+
"header": str(raw.get("header") or ""),
|
|
68
|
+
"question": str(raw.get("question") or ""),
|
|
69
|
+
"options": options,
|
|
70
|
+
"multi_select": bool(raw.get("multi_select")),
|
|
71
|
+
"status": str(raw.get("status") or PENDING),
|
|
72
|
+
"asked_at": raw.get("asked_at"),
|
|
73
|
+
"answer": answer,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_questions(raw: object) -> list[dict[str, Any]]:
|
|
78
|
+
"""Normalise a `human_questions` list (from the run or an `ask_human` event)."""
|
|
79
|
+
if not isinstance(raw, list):
|
|
80
|
+
return []
|
|
81
|
+
return [q for q in (_question(item) for item in raw) if q is not None]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def questions_by_turn(questions: Iterable[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
|
|
85
|
+
"""Group questions by `turn_index`, keeping the order they were asked in."""
|
|
86
|
+
grouped: dict[int, list[dict[str, Any]]] = {}
|
|
87
|
+
for q in questions:
|
|
88
|
+
grouped.setdefault(int(q.get("turn_index") or 0), []).append(q)
|
|
89
|
+
return grouped
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_answer_turn(item: object) -> bool:
|
|
93
|
+
"""Whether a `result.results[i]` entry is a turn opened by the user's answers."""
|
|
94
|
+
if not isinstance(item, dict):
|
|
95
|
+
return False
|
|
96
|
+
inp = item.get("input")
|
|
97
|
+
return isinstance(inp, dict) and inp.get("source") == HUMAN_ANSWER_SOURCE
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def has_pending(questions: Iterable[dict[str, Any]]) -> bool:
|
|
101
|
+
return any(q.get("status") == PENDING for q in questions)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class QuestionDraft:
|
|
106
|
+
"""What the user has picked or typed on one card, before submitting."""
|
|
107
|
+
|
|
108
|
+
selected: list[str] = field(default_factory=list)
|
|
109
|
+
text: str = ""
|
|
110
|
+
#: The user pressed Skip on the card: sent as `skip: true`, without choices or text.
|
|
111
|
+
skipped: bool = False
|
|
112
|
+
|
|
113
|
+
def is_filled(self) -> bool:
|
|
114
|
+
return bool(self.selected) or bool(self.text.strip())
|
|
115
|
+
|
|
116
|
+
def is_handled(self) -> bool:
|
|
117
|
+
return self.skipped or self.is_filled()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def draft_is_valid(question: dict[str, Any], draft: QuestionDraft | None) -> bool:
|
|
121
|
+
"""A draft the backend would accept, or one not handled yet."""
|
|
122
|
+
if draft is None or draft.skipped or not draft.is_filled():
|
|
123
|
+
return True
|
|
124
|
+
labels = {o["label"] for o in question.get("options") or []}
|
|
125
|
+
if any(s not in labels for s in draft.selected):
|
|
126
|
+
return False
|
|
127
|
+
if not question.get("multi_select") and len(draft.selected) > 1:
|
|
128
|
+
return False
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_answers(
|
|
133
|
+
questions: Iterable[dict[str, Any]], drafts: dict[str, QuestionDraft]
|
|
134
|
+
) -> list[dict[str, Any]]:
|
|
135
|
+
"""The `answers` body of `POST /actions/{run_id}/answer`: one item per pending card, in
|
|
136
|
+
card order, answered or `skip: true`. The backend refuses a call that leaves any open
|
|
137
|
+
question out, so `can_submit` must hold first."""
|
|
138
|
+
answers: list[dict[str, Any]] = []
|
|
139
|
+
for q in questions:
|
|
140
|
+
if q.get("status") != PENDING:
|
|
141
|
+
continue
|
|
142
|
+
draft = drafts.get(q["question_id"])
|
|
143
|
+
if draft is not None and draft.skipped:
|
|
144
|
+
answers.append({"question_id": q["question_id"], "skip": True})
|
|
145
|
+
continue
|
|
146
|
+
if draft is None or not draft.is_filled():
|
|
147
|
+
continue
|
|
148
|
+
item: dict[str, Any] = {"question_id": q["question_id"], "selected": list(draft.selected)}
|
|
149
|
+
text = draft.text.strip()
|
|
150
|
+
if text:
|
|
151
|
+
item["text"] = text
|
|
152
|
+
answers.append(item)
|
|
153
|
+
return answers
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def can_submit(questions: Iterable[dict[str, Any]], drafts: dict[str, QuestionDraft]) -> bool:
|
|
157
|
+
"""Every pending card is answered (validly) or skipped: one call handles them all."""
|
|
158
|
+
pending = [q for q in questions if q.get("status") == PENDING]
|
|
159
|
+
if not pending:
|
|
160
|
+
return False
|
|
161
|
+
for q in pending:
|
|
162
|
+
draft = drafts.get(q["question_id"])
|
|
163
|
+
if draft is None or not draft.is_handled() or not draft_is_valid(q, draft):
|
|
164
|
+
return False
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def with_status(
|
|
169
|
+
questions: Iterable[dict[str, Any]],
|
|
170
|
+
status: str,
|
|
171
|
+
*,
|
|
172
|
+
question_ids: Iterable[str] | None = None,
|
|
173
|
+
answers: Iterable[dict[str, Any]] | None = None,
|
|
174
|
+
) -> list[dict[str, Any]]:
|
|
175
|
+
"""Copies of `questions` with the pending ones (or only `question_ids`) set to `status`;
|
|
176
|
+
a question whose answer item is a skip becomes `skipped` instead.
|
|
177
|
+
|
|
178
|
+
Used to update the screen before the backend's copy arrives; the next fetch of the run
|
|
179
|
+
replaces it.
|
|
180
|
+
"""
|
|
181
|
+
ids = set(question_ids) if question_ids is not None else None
|
|
182
|
+
by_id = {a["question_id"]: a for a in (answers or [])}
|
|
183
|
+
out = []
|
|
184
|
+
for q in questions:
|
|
185
|
+
q = dict(q)
|
|
186
|
+
if q.get("status") == PENDING and (ids is None or q["question_id"] in ids):
|
|
187
|
+
q["status"] = status
|
|
188
|
+
a = by_id.get(q["question_id"])
|
|
189
|
+
if a is not None:
|
|
190
|
+
if a.get("skip"):
|
|
191
|
+
q["status"] = SKIPPED
|
|
192
|
+
q["answer"] = {
|
|
193
|
+
"selected": list(a.get("selected") or []),
|
|
194
|
+
"text": a.get("text"),
|
|
195
|
+
"skipped": bool(a.get("skip")),
|
|
196
|
+
"answered_by": None,
|
|
197
|
+
"answered_at": None,
|
|
198
|
+
}
|
|
199
|
+
out.append(q)
|
|
200
|
+
return out
|