typesafeai-cli 0.3.1__py3-none-any.whl

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.
Files changed (44) hide show
  1. typesafe_cli/__init__.py +1 -0
  2. typesafe_cli/cli.py +93 -0
  3. typesafe_cli/client.py +106 -0
  4. typesafe_cli/commands/__init__.py +0 -0
  5. typesafe_cli/commands/agent.py +12 -0
  6. typesafe_cli/commands/ask.py +90 -0
  7. typesafe_cli/commands/auth.py +28 -0
  8. typesafe_cli/commands/decide.py +43 -0
  9. typesafe_cli/commands/eval.py +37 -0
  10. typesafe_cli/commands/extract.py +83 -0
  11. typesafe_cli/commands/find.py +53 -0
  12. typesafe_cli/commands/models.py +30 -0
  13. typesafe_cli/commands/oneshot.py +91 -0
  14. typesafe_cli/commands/rank.py +49 -0
  15. typesafe_cli/commands/screen.py +38 -0
  16. typesafe_cli/commands/skills.py +138 -0
  17. typesafe_cli/commands/smoke.py +35 -0
  18. typesafe_cli/commands/suggest_skill.py +48 -0
  19. typesafe_cli/commands/verify.py +53 -0
  20. typesafe_cli/config.py +37 -0
  21. typesafe_cli/data/__init__.py +0 -0
  22. typesafe_cli/data/typesafe-ai/CLI.md +7 -0
  23. typesafe_cli/data/typesafe-ai/SKILL.md +149 -0
  24. typesafe_cli/data/typesafe-cli/SKILL.md +137 -0
  25. typesafe_cli/detect.py +83 -0
  26. typesafe_cli/format.py +22 -0
  27. typesafe_cli/help.py +36 -0
  28. typesafe_cli/io.py +17 -0
  29. typesafe_cli/questions.py +96 -0
  30. typesafe_cli/recipes/__init__.py +3 -0
  31. typesafe_cli/recipes/decide.py +64 -0
  32. typesafe_cli/recipes/extract.py +42 -0
  33. typesafe_cli/recipes/find.py +90 -0
  34. typesafe_cli/recipes/rank.py +97 -0
  35. typesafe_cli/recipes/screen.py +68 -0
  36. typesafe_cli/recipes/skills.py +148 -0
  37. typesafe_cli/recipes/thresholds.py +24 -0
  38. typesafe_cli/recipes/verify.py +48 -0
  39. typesafe_cli/schema.py +150 -0
  40. typesafeai_cli-0.3.1.dist-info/METADATA +137 -0
  41. typesafeai_cli-0.3.1.dist-info/RECORD +44 -0
  42. typesafeai_cli-0.3.1.dist-info/WHEEL +4 -0
  43. typesafeai_cli-0.3.1.dist-info/entry_points.txt +2 -0
  44. typesafeai_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: typesafe-cli
3
+ description: >
4
+ Drive Jev through the typesafe CLI. Use when you need a typed snap judgment
5
+ over local task context: route, yes/no, score, search a file, rerank hits,
6
+ extract a span, verify a claim, screen a message, or pick a skill.
7
+ Collect privacy-safe state yourself, then run typesafe find/rank/extract/
8
+ verify/screen/suggest-skill/decide or ask. Do not curl TypeSafe, do not write
9
+ throwaway SDK scripts, do not read TYPESAFE_* env vars or key files.
10
+ Triggers: typesafe CLI, Jev, noul, choice, score, find, rank, extract, verify,
11
+ screen, suggest-skill, decide, typed judgment, which skill, is this urgent.
12
+ ---
13
+
14
+ # typesafe CLI
15
+
16
+ Jev judges only the `state` and `questions` you send. It cannot see the repo, the diff, AGENTS.md, skills, MCP, memory, or prior tool results. A label like "the code change" or a file path with no body is not context. If the decision is about code, you must copy the relevant slices into `state`.
17
+
18
+ You are the context adapter. The CLI is the pipe.
19
+
20
+ ## Scratch files
21
+
22
+ Never write `state.json`, `questions.json`, or other TypeSafe payloads into the git working tree.
23
+
24
+ Put them in a **private temp directory** scoped to this project:
25
+
26
+ ```bash
27
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
28
+ WORKDIR="${TMPDIR:-/tmp}/codex/$(basename "$ROOT")"
29
+ mkdir -p "$WORKDIR"
30
+ ```
31
+
32
+ Then `$WORKDIR/state.json` and `$WORKDIR/questions.json`. For a one-line state, prefer `--state "…"` and skip files.
33
+
34
+ Deleting those files after the call is good. Leaving them is fine. Do not commit them.
35
+
36
+ ## Secrets
37
+
38
+ The CLI loads credentials. You never do.
39
+
40
+ Run `typesafe auth status`. Read `data.has_key` only. If it is false, ask the human. Do not open `~/.config/typesafe/env`, `.env.local`, or print `TYPESAFE_*`.
41
+
42
+ ## Procedure
43
+
44
+ ### 1. Confirm the binary and a key
45
+
46
+ ```bash
47
+ typesafe auth status
48
+ ```
49
+
50
+ Done when `has_key` is true. If the command is missing, tell the human to install typesafeai-cli.
51
+
52
+ ### 2. Name the decision, then write the questions
53
+
54
+ Do not start by dumping context. TypeSafe's contract is the questions: they name the fields Jev is allowed to look at. When a dedicated command exists (`find`, `rank`, `extract`, `verify`, `screen`, `suggest-skill`, `decide`), **its flags are the contract** — fill those instead of a free-form dump.
55
+
56
+ One sentence: what you will **do** with the answer. If that is not a noul / choice / score, decide locally.
57
+
58
+ Then write `$WORKDIR/questions.json`. Each question's `instructions` must point at named paths with backticks (`` `task` ``, `` `files[0].hunk` ``, `` `tools` ``). Those paths **are** the collection list. There is no catalog of agent jobs to encode in this skill.
59
+
60
+ - `noul` — is this statement true?
61
+ - `choice` — pick one; **≥2** options; include `other` / `none` / `abstain`
62
+ - `score` — ordered rubric; **≥2** levels
63
+
64
+ Batch independent questions. One judgment each. Meaning lives in `instructions`, not in the id.
65
+
66
+ Done when every question names the fields it needs.
67
+
68
+ ### 3. Fill only those fields
69
+
70
+ Read the repo, diff, tests, or tool list **here**. Put into `$WORKDIR/state.json` **only** the paths the questions reference.
71
+
72
+ Completeness: a stranger could answer the questions from `state.json` alone.
73
+
74
+ If a path is code (`` `files[0].hunk` ``), the value must be the slice (path + line range + body), not a filename and not "see the PR." Prefer under ~8k tokens of code. Do not send the tree.
75
+
76
+ Redact secrets. If a required slice cannot be redacted, **do not call TypeSafe**.
77
+
78
+ ### 4. Call the CLI
79
+
80
+ ```bash
81
+ typesafe ask --state-file "$WORKDIR/state.json" --questions-file "$WORKDIR/questions.json"
82
+ ```
83
+
84
+ One-shot: `typesafe noul "…" --state "…"` / `choice` / `score`. Prefer `ask` for more than one question.
85
+
86
+ Read stdout JSON: `data.model` should be a Jev id (`jev-1.13.0`). `data.answers` is the result. Do not parse prose; there is none.
87
+
88
+ ### 5. Apply the answer here
89
+
90
+ Thresholds are yours, not Jev's.
91
+
92
+ - Noul near 0.5 is **unsure**, not medium. Do not automate.
93
+ - Choice/score `confidence` low → abstain or ask the human.
94
+ - Then you pick the skill, run the tool, or stop. Jev does not choose the next action.
95
+
96
+ ## Combine calls (do not loop)
97
+
98
+ Jev answers are independent. Accuracy comes from **the right sequence**, not from more chat. Local work first when the cookbook does; one `system_one` for every independent question about the same state.
99
+
100
+ | Goal | Do this | Do not |
101
+ | --- | --- | --- |
102
+ | Many questions, one document | One `typesafe ask` with all of them | `noul` in a shell loop (pays for the document N times) |
103
+ | Search a file | `typesafe find --file --query` (or `ask` with line ids as a Choice **and** an exists Noul in the same request) | Rank lines without asking whether an answer exists |
104
+ | Many candidates | `typesafe rank` (or one batched `ask`) | One HTTP call per hit |
105
+ | Pull a value out of text | Find candidates locally (regex/roster), then `typesafe extract` / Choice among those spans plus `none` | Ask Jev to generate the email/amount |
106
+ | Check a claim | String-match the quote locally; if missing → fabricated. Else `typesafe verify` | Send only the claim with no source |
107
+ | Gate a message | `typesafe screen` then your policy | Skip the gate and “be careful” |
108
+ | Which skill to load | `typesafe suggest-skill` (cheap rank, then reread top 3). Treat the name as a hint | Load three skills because the names look similar |
109
+ | 0.49 vs 0.51 | `typesafe decide` / a review band in code | Flip automation on a coin-flip noul |
110
+
111
+ If a dedicated verb is the wrong fit, compose the same sequence with `ask`: batch questions, put candidates in `state`, run local checks before HTTP.
112
+
113
+ Second request only when the first answer is required to fetch more evidence or to shrink options (skill shortlist, Choice over 255+ lines). Speculative extras belong in the **first** call.
114
+
115
+ ## Commands
116
+
117
+ ```bash
118
+ typesafe auth status
119
+ typesafe agent schema
120
+ typesafe ask --state-file "$WORKDIR/state.json" --questions-file "$WORKDIR/questions.json"
121
+ typesafe noul "…" --state "…"
122
+ typesafe choice "…" --option a --option b --state "…"
123
+ typesafe score "…" --level low --level high --state "…"
124
+ typesafe find --file path --query "…"
125
+ typesafe rank --query "…" --candidates-file items.json
126
+ typesafe extract --file doc.txt --pattern email --question "…"
127
+ typesafe verify --claim "…" --source-file rfc.txt
128
+ typesafe screen --text-file msg.txt
129
+ typesafe suggest-skill --task "…" --skills-dir ~/.agents/skills
130
+ typesafe decide --answers-file "$WORKDIR/last.json"
131
+ ```
132
+
133
+ Discover flags with `typesafe agent schema`. Do not invent request fields.
134
+
135
+ ## Official TypeSafe skill
136
+
137
+ `typesafe-ai` is for **designing** TypeSafe into application code (SDK). This skill is for **running** Jev now through the CLI. Live evaluations in a coding session use this skill.
typesafe_cli/detect.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from contextvars import ContextVar
6
+ from dataclasses import dataclass
7
+
8
+ _force_agent: ContextVar[bool | None] = ContextVar("typesafe_force_agent", default=None)
9
+
10
+
11
+ def set_force_agent(value: bool | None) -> None:
12
+ _force_agent.set(value)
13
+
14
+ DETECTORS: tuple[tuple[str, tuple[str, ...]], ...] = (
15
+ ("claude-code", ("CLAUDECODE", "CLAUDE_CODE")),
16
+ ("cursor", ("CURSOR_AGENT",)),
17
+ ("codex", ("CODEX", "OPENAI_CODEX")),
18
+ ("opencode", ("OPENCODE",)),
19
+ ("aider", ("AIDER",)),
20
+ ("cline", ("CLINE",)),
21
+ ("windsurf", ("WINDSURF_AGENT",)),
22
+ ("github-copilot", ("GITHUB_COPILOT",)),
23
+ ("amazon-q", ("AMAZON_Q", "AWS_Q_DEVELOPER")),
24
+ ("gemini-code", ("GEMINI_CODE_ASSIST",)),
25
+ ("sourcegraph-cody", ("SRC_CODY",)),
26
+ ("grok", ("GROK", "GROK_CODE")),
27
+ ("generic-agent", ("AGENT",)),
28
+ )
29
+
30
+ ALL_ENV_VARS: tuple[str, ...] = tuple(var for _, vars_ in DETECTORS for var in vars_) + (
31
+ "FORCE_AGENT_MODE",
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AgentInfo:
37
+ name: str
38
+ detected: bool
39
+
40
+
41
+ def _truthy(key: str) -> bool:
42
+ value = os.environ.get(key)
43
+ if value is None:
44
+ return False
45
+ return value.lower() in {"1", "true", "yes"}
46
+
47
+
48
+ def detect_agent_info() -> AgentInfo:
49
+ for name, env_vars in DETECTORS:
50
+ if any(_truthy(var) for var in env_vars):
51
+ return AgentInfo(name=name, detected=True)
52
+ return AgentInfo(name="", detected=False)
53
+
54
+
55
+ def is_agent_mode(*, argv: list[str] | None = None) -> bool:
56
+ forced = _force_agent.get()
57
+ if forced is not None:
58
+ return forced
59
+ args = sys.argv if argv is None else argv
60
+ if "--no-agent" in args:
61
+ return False
62
+ if "--agent" in args:
63
+ return True
64
+ if _truthy("FORCE_AGENT_MODE"):
65
+ return True
66
+ return detect_agent_info().detected
67
+
68
+
69
+ def resolve_agent(*, target: str | None = None) -> str:
70
+ if target and target not in {"auto", ""}:
71
+ aliases = {
72
+ "claude": "claude-code",
73
+ "claude-code": "claude-code",
74
+ "cursor": "cursor",
75
+ "codex": "codex",
76
+ "opencode": "opencode",
77
+ "grok": "grok",
78
+ "gemini": "gemini-code",
79
+ "windsurf": "windsurf",
80
+ }
81
+ return aliases.get(target, target)
82
+ info = detect_agent_info()
83
+ return info.name or "generic-agent"
typesafe_cli/format.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def success_envelope(*, data: dict[str, Any], command: str) -> dict[str, Any]:
8
+ return {"status": "success", "data": data, "metadata": {"command": command}}
9
+
10
+
11
+ def error_envelope(*, code: str, message: str) -> dict[str, Any]:
12
+ return {"status": "error", "error": {"code": code, "message": message}}
13
+
14
+
15
+ def dumps(obj: dict[str, Any]) -> str:
16
+ return json.dumps(obj, indent=2, default=_json_default) + "\n"
17
+
18
+
19
+ def _json_default(value: Any) -> Any:
20
+ if isinstance(value, dict):
21
+ return {str(k): v for k, v in value.items()}
22
+ return str(value)
typesafe_cli/help.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typer.core import TyperCommand, TyperGroup
4
+
5
+ from typesafe_cli.detect import is_agent_mode
6
+ from typesafe_cli.format import dumps, success_envelope
7
+ from typesafe_cli.schema import schema_for_help
8
+
9
+
10
+ def _help_is_agent(ctx) -> bool: # noqa: ANN001 — click Context
11
+ if ctx.params.get("no_agent"):
12
+ return False
13
+ if ctx.params.get("agent"):
14
+ return True
15
+ parent = ctx.parent
16
+ while parent is not None:
17
+ if parent.params.get("no_agent"):
18
+ return False
19
+ if parent.params.get("agent"):
20
+ return True
21
+ parent = parent.parent
22
+ return is_agent_mode()
23
+
24
+
25
+ class AgentGroup(TyperGroup):
26
+ def get_help(self, ctx) -> str: # noqa: ANN001
27
+ if _help_is_agent(ctx):
28
+ return dumps(success_envelope(data=schema_for_help(ctx), command="help")).rstrip("\n")
29
+ return super().get_help(ctx)
30
+
31
+
32
+ class AgentCommand(TyperCommand):
33
+ def get_help(self, ctx) -> str: # noqa: ANN001
34
+ if _help_is_agent(ctx):
35
+ return dumps(success_envelope(data=schema_for_help(ctx), command="help")).rstrip("\n")
36
+ return super().get_help(ctx)
typesafe_cli/io.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any, NoReturn
5
+
6
+ import typer
7
+
8
+ from typesafe_cli.format import dumps, error_envelope, success_envelope
9
+
10
+
11
+ def emit_success(*, data: dict[str, Any], command: str) -> None:
12
+ sys.stdout.write(dumps(success_envelope(data=data, command=command)))
13
+
14
+
15
+ def fail(*, code: str, message: str, exit_code: int) -> NoReturn:
16
+ sys.stderr.write(dumps(error_envelope(code=code, message=message)))
17
+ raise typer.Exit(exit_code)
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ class QuestionError(ValueError):
9
+ pass
10
+
11
+
12
+ ALLOWED_TYPES = {"noul", "choice", "score"}
13
+
14
+
15
+ def load_state(raw: str | dict | list) -> str | dict | list:
16
+ return raw
17
+
18
+
19
+ def parse_state_text(text: str) -> str | dict | list:
20
+ stripped = text.strip()
21
+ if not stripped:
22
+ raise QuestionError("state is empty")
23
+ if stripped[0] in "{[":
24
+ try:
25
+ return json.loads(stripped)
26
+ except json.JSONDecodeError:
27
+ return text
28
+ return text
29
+
30
+
31
+ def load_state_file(path: Path) -> str | dict | list:
32
+ return parse_state_text(path.read_text(encoding="utf-8"))
33
+
34
+
35
+ def load_questions_file(path: Path) -> tuple[dict[str, dict[str, Any]], str | dict | list | None]:
36
+ payload = json.loads(path.read_text(encoding="utf-8"))
37
+ if not isinstance(payload, dict):
38
+ raise QuestionError("questions file must be a JSON object")
39
+ embedded_state = payload.get("state")
40
+ return load_questions(payload), embedded_state
41
+
42
+
43
+ def load_questions(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
44
+ if "questions" in data:
45
+ raw = data["questions"]
46
+ if isinstance(raw, list):
47
+ mapping: dict[str, Any] = {}
48
+ for item in raw:
49
+ if not isinstance(item, dict) or "id" not in item:
50
+ raise QuestionError("list questions must each have an id")
51
+ qid = item["id"]
52
+ if qid in mapping:
53
+ raise QuestionError(f"duplicate question id: {qid}")
54
+ mapping[qid] = {k: v for k, v in item.items() if k != "id"}
55
+ raw = mapping
56
+ if not isinstance(raw, dict):
57
+ raise QuestionError("questions must be an object or a list")
58
+ source = raw
59
+ else:
60
+ source = {k: v for k, v in data.items() if k != "state"}
61
+
62
+ if not source:
63
+ raise QuestionError("no questions provided")
64
+
65
+ out: dict[str, dict[str, Any]] = {}
66
+ for qid, question in source.items():
67
+ if not isinstance(qid, str) or not qid:
68
+ raise QuestionError("question ids must be non-empty strings")
69
+ if not isinstance(question, dict):
70
+ raise QuestionError(f"question {qid} must be an object")
71
+ out[qid] = _validate_question(qid, question)
72
+ return out
73
+
74
+
75
+ def _validate_question(qid: str, question: dict[str, Any]) -> dict[str, Any]:
76
+ qtype = question.get("type")
77
+ if qtype not in ALLOWED_TYPES:
78
+ raise QuestionError(f"{qid}: type must be noul, choice, or score")
79
+ instructions = question.get("instructions")
80
+ if instructions is None or instructions == "":
81
+ raise QuestionError(f"{qid}: instructions are required")
82
+
83
+ criteria = question.get("criteria")
84
+ if qtype == "choice":
85
+ if not isinstance(criteria, dict) or len(criteria) < 2:
86
+ raise QuestionError(f"{qid}: choice criteria must be a map with at least two options")
87
+ elif qtype == "score":
88
+ if not isinstance(criteria, list) or len(criteria) < 2:
89
+ raise QuestionError(f"{qid}: score criteria must be a list with at least two levels")
90
+ elif criteria is not None and not isinstance(criteria, dict):
91
+ raise QuestionError(f"{qid}: noul criteria must be an object if present")
92
+
93
+ validated: dict[str, Any] = {"type": qtype, "instructions": instructions}
94
+ if criteria is not None:
95
+ validated["criteria"] = criteria
96
+ return validated
@@ -0,0 +1,3 @@
1
+ from typesafe_cli.recipes.thresholds import CHOICE_MAX_OPTIONS
2
+
3
+ __all__ = ["CHOICE_MAX_OPTIONS"]
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from typesafe_cli.questions import QuestionError
6
+ from typesafe_cli.recipes.thresholds import CHOICE_MIN_TOP_P, NOUL_UNCERTAIN_HIGH, NOUL_UNCERTAIN_LOW
7
+
8
+
9
+ def parse_noul_band(raw: str) -> tuple[float, float]:
10
+ try:
11
+ low_s, high_s = raw.split(":", 1)
12
+ low, high = float(low_s), float(high_s)
13
+ except ValueError as exc:
14
+ raise QuestionError("noul band must be low:high, e.g. 0.30:0.70") from exc
15
+ if not 0.0 <= low <= high <= 1.0:
16
+ raise QuestionError("noul band must satisfy 0 <= low <= high <= 1")
17
+ return low, high
18
+
19
+
20
+ def extract_answers(payload: Any) -> dict[str, Any]:
21
+ if not isinstance(payload, dict):
22
+ raise QuestionError("answers file must be a JSON object")
23
+ if "answers" in payload and isinstance(payload["answers"], dict):
24
+ return payload["answers"]
25
+ data = payload.get("data")
26
+ if isinstance(data, dict) and isinstance(data.get("answers"), dict):
27
+ return data["answers"]
28
+ if payload and all(isinstance(v, dict) and "type" in v for v in payload.values()):
29
+ return payload
30
+ raise QuestionError("could not find answers in file (expected data.answers or answers)")
31
+
32
+
33
+ def apply_decide(
34
+ answers: dict[str, Any],
35
+ *,
36
+ noul_low: float = NOUL_UNCERTAIN_LOW,
37
+ noul_high: float = NOUL_UNCERTAIN_HIGH,
38
+ choice_min_p: float = CHOICE_MIN_TOP_P,
39
+ ) -> dict[str, Any]:
40
+ out: dict[str, Any] = {}
41
+ for qid, answer in answers.items():
42
+ if not isinstance(answer, dict):
43
+ raise QuestionError(f"{qid}: answer must be an object")
44
+ qtype = answer.get("type")
45
+ if qtype == "noul":
46
+ probability = float(answer["noul"])
47
+ if probability < noul_low:
48
+ decision = "no"
49
+ elif probability > noul_high:
50
+ decision = "yes"
51
+ else:
52
+ decision = "uncertain"
53
+ out[qid] = {**answer, "decision": decision, "noul": probability}
54
+ elif qtype == "choice":
55
+ probabilities = {str(k): float(v) for k, v in dict(answer.get("probabilities") or {}).items()}
56
+ top_p = max(probabilities.values()) if probabilities else 0.0
57
+ winner = answer.get("choice")
58
+ decision = winner if top_p >= choice_min_p else "uncertain"
59
+ out[qid] = {**answer, "decision": decision, "top_probability": top_p}
60
+ elif qtype == "score":
61
+ out[qid] = {**answer, "decision": answer.get("score")}
62
+ else:
63
+ raise QuestionError(f"{qid}: unsupported answer type {qtype!r}")
64
+ return out
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from typesafe_cli.questions import QuestionError
7
+
8
+ PATTERN_NAMES = ("email", "phone", "money")
9
+
10
+ _PATTERNS = {
11
+ "email": re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"),
12
+ "phone": re.compile(r"\+?\d[\d.\-\s()]{7,}\d"),
13
+ "money": re.compile(r"\$\d{1,3}(?:,\d{3})*(?:\.\d{2})?|\b\d+\.\d{2}\b"),
14
+ }
15
+
16
+
17
+ def find_spans(text: str, pattern: str) -> list[str]:
18
+ if pattern not in _PATTERNS:
19
+ raise QuestionError(f"unknown pattern {pattern!r}; use {', '.join(PATTERN_NAMES)} or --candidates")
20
+ seen: list[str] = []
21
+ for match in _PATTERNS[pattern].findall(text):
22
+ span = match.strip()
23
+ if span and span not in seen:
24
+ seen.append(span)
25
+ return seen
26
+
27
+
28
+ def extract_questions(spans: list[str], question: str) -> dict[str, Any]:
29
+ criteria: dict[str, str | None] = {span: None for span in spans}
30
+ criteria["none"] = "None of the candidate spans is the requested value"
31
+ if len(criteria) < 2:
32
+ raise QuestionError("extract needs at least one candidate span plus none")
33
+ return {
34
+ "value": {
35
+ "type": "choice",
36
+ "instructions": (
37
+ f"{question} Pick the verbatim candidate span. "
38
+ "If none of the candidates is correct, choose none."
39
+ ),
40
+ "criteria": criteria,
41
+ }
42
+ }
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Iterator, Sequence
4
+ from typing import Any
5
+
6
+ from typesafe_cli.recipes.thresholds import FIND_CHUNK, FIND_EXISTS_ANSWERED, FIND_EXISTS_PARTIAL
7
+
8
+
9
+ def tag_lines(text: str) -> list[dict[str, str]]:
10
+ raw_lines = text.splitlines()
11
+ width = max(3, len(str(max(len(raw_lines) - 1, 0))))
12
+ return [{"id": f"L{index:0{width}d}", "text": line} for index, line in enumerate(raw_lines)]
13
+
14
+
15
+ def tagged_state(lines: Sequence[dict[str, str]]) -> str:
16
+ return "\n".join(f"{line['id']}| {line['text']}" for line in lines)
17
+
18
+
19
+ def windows(items: Sequence[Any], *, size: int) -> Iterator[list[Any]]:
20
+ if size < 1:
21
+ raise ValueError("window size must be >= 1")
22
+ for start in range(0, len(items), size):
23
+ yield list(items[start : start + size])
24
+
25
+
26
+ def find_questions(lines: Sequence[dict[str, str]], query: str) -> dict[str, Any]:
27
+ criteria: dict[str, str | None] = {line["id"]: None for line in lines}
28
+ if len(criteria) < 2:
29
+ criteria["none"] = "No line in this window answers the query"
30
+ return {
31
+ "line": {
32
+ "type": "choice",
33
+ "instructions": (
34
+ "Which tagged line best answers the query? "
35
+ f"Query: {query}. Prefer the most specific matching line id."
36
+ ),
37
+ "criteria": criteria,
38
+ },
39
+ "exists": {
40
+ "type": "noul",
41
+ "instructions": f"Does this document contain an answer to the query `{query}`?",
42
+ },
43
+ }
44
+
45
+
46
+ def verdict_for_exists(exists: float) -> str:
47
+ if exists >= FIND_EXISTS_ANSWERED:
48
+ return "answered"
49
+ if exists >= FIND_EXISTS_PARTIAL:
50
+ return "partial"
51
+ return "absent"
52
+
53
+
54
+ def merge_find_results(
55
+ *,
56
+ lines_by_id: dict[str, str],
57
+ chunks: Iterable[dict[str, Any]],
58
+ ) -> dict[str, Any]:
59
+ combined: dict[str, float] = {}
60
+ exists_values: list[float] = []
61
+ model = None
62
+ usage = {"input_tokens": 0, "output_tokens": 0}
63
+ for chunk in chunks:
64
+ model = chunk.get("model") or model
65
+ chunk_usage = chunk.get("usage") or {}
66
+ usage["input_tokens"] += int(chunk_usage.get("input_tokens") or 0)
67
+ usage["output_tokens"] += int(chunk_usage.get("output_tokens") or 0)
68
+ answers = chunk["answers"]
69
+ exists_values.append(float(answers["exists"]["noul"]))
70
+ probabilities = answers["line"].get("probabilities") or {}
71
+ for line_id, probability in probabilities.items():
72
+ if line_id == "none":
73
+ continue
74
+ combined[str(line_id)] = max(combined.get(str(line_id), 0.0), float(probability))
75
+ ranked = sorted(combined.items(), key=lambda item: item[1], reverse=True)
76
+ exists = max(exists_values) if exists_values else 0.0
77
+ return {
78
+ "model": model,
79
+ "query_lines": [
80
+ {"id": line_id, "text": lines_by_id.get(line_id, ""), "probability": probability}
81
+ for line_id, probability in ranked
82
+ if line_id in lines_by_id
83
+ ],
84
+ "exists": exists,
85
+ "verdict": verdict_for_exists(exists),
86
+ "usage": usage,
87
+ }
88
+
89
+
90
+ FIND_CHUNK = FIND_CHUNK