lexguard 0.1.0__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.
@@ -0,0 +1,117 @@
1
+ name: CI and Publish
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+ push:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v5
17
+
18
+ - name: Set up Python
19
+ run: uv python install 3.12
20
+
21
+ - name: Install dependencies
22
+ run: uv sync --all-extras --frozen
23
+
24
+ - name: Lint with ruff
25
+ run: uv run ruff check .
26
+
27
+ - name: Format check with ruff
28
+ run: uv run ruff format --check .
29
+
30
+ - name: Type check with basedpyright
31
+ run: uv run basedpyright
32
+
33
+ - name: Run unit tests (parallel)
34
+ run: uv run pytest -m unit -n auto --cov=src/lexguard --cov-report=term-missing
35
+
36
+ - name: Run integration tests (sequential)
37
+ run: uv run pytest -m integration --cov=src/lexguard --cov-append --cov-report=term-missing
38
+
39
+ publish:
40
+ needs: test
41
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
42
+ runs-on: ubuntu-latest
43
+ environment:
44
+ name: pypi
45
+ url: https://pypi.org/p/lexguard
46
+ permissions:
47
+ id-token: write
48
+ contents: write
49
+
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ with:
53
+ fetch-depth: 0
54
+ token: ${{ secrets.GITHUB_TOKEN }}
55
+ - name: Install uv
56
+ uses: astral-sh/setup-uv@v5
57
+
58
+ - name: Set up Python
59
+ run: uv python install 3.12
60
+
61
+ - name: Install dependencies
62
+ run: uv sync --all-extras --frozen
63
+
64
+ - name: Configure git
65
+ run: |
66
+ git config user.name "github-actions[bot]"
67
+ git config user.email "github-actions[bot]@users.noreply.github.com"
68
+
69
+ - name: Bump version
70
+ run: uv run bump-my-version bump patch
71
+
72
+ - name: Push version bump
73
+ run: git push --follow-tags
74
+
75
+ - name: Build package
76
+ run: uv build
77
+
78
+ - name: Publish to PyPI
79
+ uses: pypa/gh-action-pypi-publish@release/v1
80
+
81
+ docs:
82
+ needs: test
83
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
84
+ runs-on: ubuntu-latest
85
+ permissions:
86
+ contents: read
87
+ pages: write
88
+ id-token: write
89
+ environment:
90
+ name: github-pages
91
+ url: ${{ steps.deployment.outputs.page_url }}
92
+ steps:
93
+ - uses: actions/checkout@v4
94
+
95
+ - name: Install uv
96
+ uses: astral-sh/setup-uv@v5
97
+
98
+ - name: Set up Python
99
+ run: uv python install 3.12
100
+
101
+ - name: Install dependencies
102
+ run: uv sync --all-extras --frozen
103
+
104
+ - name: Build docs
105
+ run: uv run zensical build
106
+
107
+ - name: Setup Pages
108
+ uses: actions/configure-pages@v5
109
+
110
+ - name: Upload artifact
111
+ uses: actions/upload-pages-artifact@v3
112
+ with:
113
+ path: site
114
+
115
+ - name: Deploy to GitHub Pages
116
+ id: deployment
117
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,62 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # Virtual environments
25
+ .venv
26
+ venv/
27
+ ENV/
28
+ env/
29
+
30
+ # IDEs
31
+ .vscode/
32
+ .idea/
33
+ *.swp
34
+ *.swo
35
+ *~
36
+
37
+ # Testing
38
+ .pytest_cache/
39
+ .coverage
40
+ htmlcov/
41
+ .tox/
42
+
43
+ # Type checking
44
+ .mypy_cache/
45
+ .pytype/
46
+ .pyre/
47
+ .basedpyright/
48
+
49
+ # Ruff
50
+ .ruff_cache/
51
+
52
+ # Docs
53
+ site/
54
+
55
+ # OS
56
+ .DS_Store
57
+ Thumbs.db
58
+
59
+ # Project specific
60
+ *.log
61
+ .env
62
+ .env.local
@@ -0,0 +1,87 @@
1
+ default_language_version:
2
+ python: python3
3
+ default_stages: [pre-commit]
4
+ repos:
5
+ - repo: https://github.com/pre-commit/pre-commit-hooks
6
+ rev: v6.0.0
7
+ hooks:
8
+ - id: trailing-whitespace
9
+ args: [--markdown-linebreak-ext=md]
10
+ - id: end-of-file-fixer
11
+ - id: mixed-line-ending
12
+ args: [--fix=lf]
13
+ - id: check-yaml
14
+ args: [--unsafe]
15
+ - id: check-json
16
+ - id: check-toml
17
+ - id: check-merge-conflict
18
+ - id: check-case-conflict
19
+ - id: check-ast
20
+ - id: check-docstring-first
21
+ - id: debug-statements
22
+ - id: name-tests-test
23
+ args: [--pytest-test-first]
24
+ - id: detect-private-key
25
+ - id: check-added-large-files
26
+ args: [--maxkb=1000]
27
+
28
+ - repo: https://github.com/igorshubovych/markdownlint-cli
29
+ rev: v0.47.0
30
+ hooks:
31
+ - id: markdownlint
32
+ args: [--fix, --disable, MD013, MD024, MD033, MD036, MD040, MD041, MD060, --]
33
+
34
+ - repo: https://github.com/commitizen-tools/commitizen
35
+ rev: v4.12.1
36
+ hooks:
37
+ - id: commitizen
38
+ stages: [commit-msg]
39
+
40
+ - repo: https://github.com/benomahony/nasa-lsp
41
+ rev: v0.1.7
42
+ hooks:
43
+ - id: nasa-lsp
44
+ exclude: ^tests/
45
+
46
+ - repo: local
47
+ hooks:
48
+ - id: ruff-format
49
+ name: ruff-format
50
+ entry: uv run ruff format
51
+ language: system
52
+ types_or: [python, pyi]
53
+ require_serial: true
54
+ - id: ruff
55
+ name: ruff
56
+ entry: uv run ruff check --fix --exit-non-zero-on-fix
57
+ language: system
58
+ types_or: [python, pyi]
59
+ require_serial: true
60
+ - id: basedpyright
61
+ name: basedpyright
62
+ entry: uv run basedpyright
63
+ language: system
64
+ types: [python]
65
+ pass_filenames: true
66
+ require_serial: true
67
+ - id: vulture
68
+ name: vulture
69
+ entry: uv run vulture src/lexguard/ --min-confidence 80
70
+ language: system
71
+ types: [python]
72
+ files: ^src/lexguard/
73
+ pass_filenames: false
74
+ require_serial: true
75
+ - id: bandit
76
+ name: bandit
77
+ entry: uv run bandit -c pyproject.toml -r src/lexguard/
78
+ language: system
79
+ files: ^src/lexguard/
80
+ exclude: ^tests/
81
+ pass_filenames: false
82
+ - id: deptry
83
+ name: deptry
84
+ entry: uv run deptry . --extend-exclude "\.venv|tests"
85
+ language: system
86
+ pass_filenames: false
87
+ require_serial: true
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: lexguard
3
+ description: Help users work with lexguard. Use when the user asks about lexguard features, usage, or wants to check text (agent output, user requests, structured fields) for a concept like slop, sycophancy, disclaimers, or confidentiality — with or without pydantic-evals.
4
+ ---
5
+
6
+ # lexguard Skill
7
+
8
+ lexguard is a library of lexicons: named sets of words and phrases that signal a concept, plus the
9
+ words that rule it out. Ninety one ship in the box, covering both what a user asked for and what a
10
+ model produced. It is **not only** a pydantic-evals add-on — the core matching API is plain
11
+ functions over a string, useful anywhere you want to check text, and pydantic-evals evaluators are
12
+ one additional way to run the same checks.
13
+
14
+ ## When to Use This Skill
15
+
16
+ Use this skill when:
17
+
18
+ - The user wants to detect slop, sycophancy, hedging, disclaimers, confidentiality leaks, or
19
+ similar patterns in text (agent output or user requests)
20
+ - The user wants a guardrail, unit-test assertion, or log filter that fires on specific wording,
21
+ with no evals framework involved
22
+ - The user is writing pydantic-evals `Dataset`/`Case` evaluations and wants lexguard's evaluators
23
+ alongside their own
24
+ - The user wants to define a lexicon for their own domain (`Lexicon(...)`, `.extend()`, `|` to
25
+ group lexicons)
26
+ - The user asks about lexguard's API, guards (`when`/`unless`), or scoping to a field
27
+ (`field="notes"`, `field="items[]"`)
28
+
29
+ ## Two ways to run a lexicon
30
+
31
+ **Standalone** — call `.signal()`, `.fires()`, or `.denied()` directly on a string. No `Dataset`,
32
+ no `Case`, nothing from pydantic-evals needs to run:
33
+
34
+ ```python
35
+ from lexguard import Confidential
36
+
37
+
38
+ def guard(reply: str) -> str:
39
+ if Confidential.fires(reply):
40
+ raise ValueError("reply leaks a secret, blocking send")
41
+ return reply
42
+ ```
43
+
44
+ **Inside pydantic-evals** — `.absent()` / `.expected()` compile the same lexicon into an
45
+ `Evaluator`, for use in a `Dataset`:
46
+
47
+ ```python
48
+ from pydantic_evals import Case, Dataset
49
+ from lexguard import Servility, Slop
50
+
51
+ dataset = Dataset(
52
+ name="prose",
53
+ cases=[Case(name="explainer", inputs="explain database indexing")],
54
+ evaluators=[Slop.absent(), Servility.absent()],
55
+ )
56
+ ```
57
+
58
+ ## Core API
59
+
60
+ - `Lexicon.signal(text) -> Signal` — `present`, `denied`, or `absent` (three-valued, not boolean)
61
+ - `Lexicon.fires(text) -> bool` — shorthand for `signal(text) is Signal.present`
62
+ - `Lexicon.denied(text) -> bool` — shorthand for `signal(text) is Signal.denied`
63
+ - `Lexicon.hits(text)` / `Lexicon.spans(text)` — the matched terms and their positions
64
+ - `Lexicon.examples(count=4)` — sample indicator phrases, for error messages
65
+ - `Lexicon.extend(indicates=..., rules_out=..., fix=...)` — a new lexicon layered on an existing one
66
+ - `lexicon_a | lexicon_b` — a `Bundle`; `.signals(text)` reports each member separately
67
+ - `Lexicon.absent(when=..., unless=..., field=...)` / `.expected(...)` — build a pydantic-evals
68
+ `Rule` (an assertion)
69
+ - `Observe([lexicons])` — a pydantic-evals evaluator that records labels instead of assertions, for
70
+ measuring before enforcing
71
+
72
+ ## Prebuilt suites (`lexguard.suites`)
73
+
74
+ - `Bloat`, `Servility`, `Leakage`, `Overreach` — `Bundle`s grouping related output lexicons
75
+ - `PROSE` — the four bundles above, as a list of evaluators
76
+ - `ADHERENCE` — guarded checks that the output honored what the request asked for
77
+ - `GENERIC` — `PROSE + ADHERENCE`, a reasonable default suite
78
+
79
+ ## Resources
80
+
81
+ - [README.md](../../README.md) — install, the core API, both standalone and pydantic-evals usage
82
+ - [docs/writing-a-lexicon.md](../../docs/writing-a-lexicon.md) — building a `Lexicon` for your own
83
+ domain, precision-over-recall, and `rules_out`
84
+ - [docs/rules.md](../../docs/rules.md) — `.absent()`/`.expected()`, `when`/`unless` guards, field
85
+ scoping, `Observe`
86
+ - [docs/agents.md](../../docs/agents.md) — checking pydantic-ai agent output, plain or structured
87
+ - `src/lexguard/words/` — the shipped lexicons, grouped by `domain`, `instruction`, `request`,
88
+ `response`
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: lexguard
3
+ Version: 0.1.0
4
+ Summary: Lexicons that score text for a concept, with pydantic-evals evaluators built in
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic-ai-slim>=2.31.1
7
+ Requires-Dist: pydantic-evals>=1.0
@@ -0,0 +1,127 @@
1
+ # lexguard
2
+
3
+ Lexicons that score text for a concept, plus the [pydantic-evals](https://ai.pydantic.dev/evals/)
4
+ evaluators to run them as part of a `Dataset`.
5
+
6
+ A lexicon is a named set of words and phrases that signal a concept, plus the words that rule it out.
7
+ Ninety one of them ship in the box, covering what a user asked for and what a model produced.
8
+ `.signal()`, `.fires()` and `.denied()` are plain functions over text: call them directly in a
9
+ guardrail, a test, a CLI, or a log pipeline, no evals framework required. `.absent()` and
10
+ `.expected()` compile the same lexicon into a pydantic-evals evaluator when you want it running
11
+ inside a `Dataset`.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv add lexguard
17
+ ```
18
+
19
+ ## The idea
20
+
21
+ A lexicon on its own only observes. Naming a polarity turns it into a verdict.
22
+
23
+ ```py
24
+ from lexguard import Slop
25
+
26
+ print(Slop.signal("let us delve into the intricate tapestry"))
27
+ #> present
28
+ print(Slop.signal("caching skips repeated work"))
29
+ #> absent
30
+ ```
31
+
32
+ Lexicons are three valued, not two. `rules_out` exists so that wording which merely shares
33
+ vocabulary with a concept does not count as the concept.
34
+
35
+ ```py
36
+ from lexguard import HighPriority, Recurrence
37
+
38
+ print(Recurrence.signal("bin day every tuesday"))
39
+ #> present
40
+ print(Recurrence.signal("i do that every so often"))
41
+ #> denied
42
+ print(HighPriority.signal("sort it whenever, no rush"))
43
+ #> denied
44
+ ```
45
+
46
+ `denied` is not the same as `absent`. An agent setting `priority=high` on "no rush" is wrong,
47
+ where on a sentence with no priority wording at all it is merely unasked for.
48
+
49
+ ## Using it without an evals framework
50
+
51
+ `signal()`, `fires()` and `denied()` are the whole API surface at this layer: plain calls over a
52
+ string. Nothing here needs `Dataset`, `Case`, or pydantic-evals in general, so a lexicon works just
53
+ as well as a guardrail before a response goes out, a plain `assert` in a unit test, or a filter in
54
+ a log pipeline.
55
+
56
+ ```py
57
+ from lexguard import Confidential
58
+
59
+
60
+ def guard(reply: str) -> str:
61
+ if Confidential.fires(reply):
62
+ raise ValueError("reply leaks a secret, blocking send")
63
+ return reply
64
+ ```
65
+
66
+ ## Running it inside pydantic-evals
67
+
68
+ `.absent()` and `.expected()` turn a lexicon into an evaluator, for when you want the same check
69
+ running as part of a `Dataset` alongside everything else.
70
+
71
+ ```py
72
+ from pydantic_evals import Case, Dataset
73
+
74
+ from lexguard import Servility, Slop
75
+
76
+
77
+ async def agent(prompt: str) -> str:
78
+ return "Great question! Let us delve in. Hope this helps!"
79
+
80
+
81
+ dataset = Dataset(
82
+ name="prose",
83
+ cases=[Case(name="explainer", inputs="explain database indexing")],
84
+ evaluators=[Slop.absent(), Servility.absent()],
85
+ )
86
+ report = dataset.evaluate_sync(agent)
87
+ print(sorted(name for name, result in report.cases[0].assertions.items() if not result.value))
88
+ #> ['no_postamble', 'no_slop', 'no_sycophancy']
89
+ ```
90
+
91
+ ## Failures tell you what to change
92
+
93
+ ```py
94
+ from pydantic_evals import Case, Dataset
95
+
96
+ from lexguard import Slop
97
+
98
+
99
+ async def agent(prompt: str) -> str:
100
+ return "Let us delve into the intricate tapestry of indexing."
101
+
102
+
103
+ report = Dataset(
104
+ name="prose", cases=[Case(inputs="explain indexing")], evaluators=[Slop.absent()]
105
+ ).evaluate_sync(agent)
106
+ print(report.cases[0].assertions["no_slop"].reason)
107
+ """
108
+ 3 slop matches: "delve", "intricate", "tapestry"
109
+ delve -> Let us delve into the intricate tapestry of in…
110
+ intricate -> Let us delve into the intricate tapestry of indexing.
111
+ fix: swap for a plain verb or noun, or add these to the sampler ban list
112
+ """
113
+ ```
114
+
115
+ ## Docs
116
+
117
+ - [Writing a lexicon](docs/writing-a-lexicon.md) for your own domain
118
+ - [Rules](docs/rules.md) and the `when` / `unless` guards
119
+ - [Agents](docs/agents.md) under test with pydantic-ai
120
+
121
+ ## Prior art
122
+
123
+ The slop word lists overlap heavily with
124
+ [slop-forensics](https://github.com/sam-paech/slop-forensics), which derives them statistically
125
+ rather than by hand. Point `Lexicon.extend()` at that list if you want the empirical version.
126
+ The abstain semantics, where a lexicon that does not apply records nothing rather than a free pass,
127
+ is the same idea as a Snorkel labelling function returning `None`.
@@ -0,0 +1,112 @@
1
+ # Agents
2
+
3
+ Nothing here is specific to pydantic-ai. A `Dataset` task is any callable, so the rule is checking
4
+ whatever your task returns. `TestModel` just makes the examples run without a key.
5
+
6
+ ```py
7
+ from pydantic_ai import Agent
8
+ from pydantic_ai.models.test import TestModel
9
+ from pydantic_evals import Case, Dataset
10
+
11
+ from lexguard import Bloat, Servility
12
+
13
+ agent = Agent(TestModel(custom_output_text="Great question! Let us delve in. Hope this helps!"))
14
+
15
+
16
+ async def task(prompt: str) -> str:
17
+ return (await agent.run(prompt)).output
18
+
19
+
20
+ report = Dataset(
21
+ name="prose",
22
+ cases=[Case(name="explainer", inputs="explain database indexing")],
23
+ evaluators=[Bloat.absent(), Servility.absent()],
24
+ ).evaluate_sync(task)
25
+ print(sorted(name for name, result in report.cases[0].assertions.items() if not result.value))
26
+ #> ['no_postamble', 'no_slop', 'no_sycophancy']
27
+ ```
28
+
29
+ The task unwraps `.output` because a rule stringifies whatever it is handed, and an
30
+ `AgentRunResult` repr would match on its own wrapper. The alternative is passing `agent.run`
31
+ straight in and putting `field="output"` on every rule.
32
+
33
+ ## Structured output
34
+
35
+ `custom_output_args` builds the output tool call, so the agent does not retry against a text part
36
+ it cannot parse.
37
+
38
+ ```py
39
+ from pydantic import BaseModel
40
+ from pydantic_ai import Agent
41
+ from pydantic_ai.models.test import TestModel
42
+ from pydantic_evals import Case, Dataset
43
+
44
+ from lexguard import Confidential
45
+
46
+
47
+ class Ticket(BaseModel):
48
+ summary: str
49
+ internal_notes: str
50
+
51
+
52
+ agent = Agent(
53
+ TestModel(
54
+ custom_output_args={
55
+ "summary": "Customer locked out of their account.",
56
+ "internal_notes": "Their password is hunter2, reset it manually.",
57
+ }
58
+ ),
59
+ output_type=Ticket,
60
+ )
61
+
62
+
63
+ async def task(prompt: str) -> Ticket:
64
+ return (await agent.run(prompt)).output
65
+
66
+
67
+ report = Dataset(
68
+ name="triage",
69
+ cases=[Case(inputs="customer cannot log in")],
70
+ evaluators=[Confidential.absent(field="internal_notes")],
71
+ ).evaluate_sync(task)
72
+ print(report.cases[0].assertions["no_confidential"].reason)
73
+ """
74
+ 1 confidential match in internal_notes: "password"
75
+ password -> Their password is hunter2, reset it manually.
76
+ fix: redact the secret before it is persisted or echoed; store a reference, never the value
77
+ """
78
+ ```
79
+
80
+ ## Holding the agent still
81
+
82
+ `TestModel` returns one canned response regardless of the prompt. That is a feature when testing
83
+ guards, because it lets you vary only the request and see which rules arm.
84
+
85
+ ```py
86
+ from pydantic_ai import Agent
87
+ from pydantic_ai.models.test import TestModel
88
+ from pydantic_evals import Case, Dataset
89
+
90
+ from lexguard import Overclaim, UncertaintyAdmission
91
+
92
+ agent = Agent(TestModel(custom_output_text="This is guaranteed to work, it never fails."))
93
+
94
+
95
+ async def task(prompt: str) -> str:
96
+ return (await agent.run(prompt)).output
97
+
98
+
99
+ report = Dataset(
100
+ name="scoping",
101
+ cases=[Case(inputs="will this migration work?")],
102
+ evaluators=[Overclaim.absent(), UncertaintyAdmission.expected()],
103
+ ).evaluate_sync(task)
104
+ print({name: result.value for name, result in report.cases[0].assertions.items()})
105
+ #> {'no_overclaim': False, 'has_uncertainty_admission': False}
106
+ ```
107
+
108
+ ## Alongside the built in evaluators
109
+
110
+ Rules are ordinary pydantic-evals evaluators, so they sit next to `MaxToolCalls`,
111
+ `ToolCorrectness`, `IsInstance` and the rest in the same list. Reach for those first for anything
112
+ structural. Lexicons are for what the text says.
@@ -0,0 +1 @@
1
+ ../README.md