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.
- typesafe_cli/__init__.py +1 -0
- typesafe_cli/cli.py +93 -0
- typesafe_cli/client.py +106 -0
- typesafe_cli/commands/__init__.py +0 -0
- typesafe_cli/commands/agent.py +12 -0
- typesafe_cli/commands/ask.py +90 -0
- typesafe_cli/commands/auth.py +28 -0
- typesafe_cli/commands/decide.py +43 -0
- typesafe_cli/commands/eval.py +37 -0
- typesafe_cli/commands/extract.py +83 -0
- typesafe_cli/commands/find.py +53 -0
- typesafe_cli/commands/models.py +30 -0
- typesafe_cli/commands/oneshot.py +91 -0
- typesafe_cli/commands/rank.py +49 -0
- typesafe_cli/commands/screen.py +38 -0
- typesafe_cli/commands/skills.py +138 -0
- typesafe_cli/commands/smoke.py +35 -0
- typesafe_cli/commands/suggest_skill.py +48 -0
- typesafe_cli/commands/verify.py +53 -0
- typesafe_cli/config.py +37 -0
- typesafe_cli/data/__init__.py +0 -0
- typesafe_cli/data/typesafe-ai/CLI.md +7 -0
- typesafe_cli/data/typesafe-ai/SKILL.md +149 -0
- typesafe_cli/data/typesafe-cli/SKILL.md +137 -0
- typesafe_cli/detect.py +83 -0
- typesafe_cli/format.py +22 -0
- typesafe_cli/help.py +36 -0
- typesafe_cli/io.py +17 -0
- typesafe_cli/questions.py +96 -0
- typesafe_cli/recipes/__init__.py +3 -0
- typesafe_cli/recipes/decide.py +64 -0
- typesafe_cli/recipes/extract.py +42 -0
- typesafe_cli/recipes/find.py +90 -0
- typesafe_cli/recipes/rank.py +97 -0
- typesafe_cli/recipes/screen.py +68 -0
- typesafe_cli/recipes/skills.py +148 -0
- typesafe_cli/recipes/thresholds.py +24 -0
- typesafe_cli/recipes/verify.py +48 -0
- typesafe_cli/schema.py +150 -0
- typesafeai_cli-0.3.1.dist-info/METADATA +137 -0
- typesafeai_cli-0.3.1.dist-info/RECORD +44 -0
- typesafeai_cli-0.3.1.dist-info/WHEEL +4 -0
- typesafeai_cli-0.3.1.dist-info/entry_points.txt +2 -0
- 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,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
|