argus-cortex 0.4.0__tar.gz → 0.4.2__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.
Files changed (45) hide show
  1. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/CHANGELOG.md +38 -1
  2. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/CLAUDE.md +1 -1
  3. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/PKG-INFO +16 -1
  4. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/README.md +15 -0
  5. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/_version.py +2 -2
  6. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/moderation/__init__.py +2 -0
  7. argus_cortex-0.4.2/src/argus_cortex/moderation/policies.py +186 -0
  8. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/moderation/singguard.py +41 -9
  9. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_moderation.py +45 -6
  10. argus_cortex-0.4.2/tests/test_policies.py +54 -0
  11. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/.copier-answers.yml +0 -0
  12. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/.env.example +0 -0
  13. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/.github/workflows/ci.yml +0 -0
  14. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/.github/workflows/release.yml +0 -0
  15. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/.gitignore +0 -0
  16. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/LICENSE +0 -0
  17. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/Makefile +0 -0
  18. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/pyproject.toml +0 -0
  19. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/__init__.py +0 -0
  20. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/backends.py +0 -0
  21. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/moderation/_http.py +0 -0
  22. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/moderation/nudenet.py +0 -0
  23. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/py.typed +0 -0
  24. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/server.py +0 -0
  25. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/__init__.py +0 -0
  26. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/blob.py +0 -0
  27. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/config.py +0 -0
  28. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/errors.py +0 -0
  29. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/lineage.py +0 -0
  30. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/models.py +0 -0
  31. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/schema.py +0 -0
  32. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/store/vector.py +0 -0
  33. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/taxonomy.py +0 -0
  34. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/wire.py +0 -0
  35. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/src/argus_cortex/xmp.py +0 -0
  36. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_backends.py +0 -0
  37. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_blob.py +0 -0
  38. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_server.py +0 -0
  39. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_smoke.py +0 -0
  40. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_store.py +0 -0
  41. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_store_pg_integration.py +0 -0
  42. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_taxonomy.py +0 -0
  43. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_vector.py +0 -0
  44. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_wire.py +0 -0
  45. {argus_cortex-0.4.0 → argus_cortex-0.4.2}/tests/test_xmp.py +0 -0
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] - 2026-10-29
11
+
12
+ ### Added
13
+ - `SingGuardClient.check_text(text, role="user")`: a text-only check, with no
14
+ image, for judging a prompt before it is run (or, with `role="assistant"`, a
15
+ model's reply). It sends `input_role` and keeps the client's policy and
16
+ thinking mode. It needs a server that takes a check without `image_base64`:
17
+ agent-composer's guard router does, while argus-cull's `singguard` service
18
+ answers HTTP 422, raised as `GuardCheckFailed`. `INPUT_ROLES` lists the roles.
19
+ - **`argus_cortex.moderation.policies`**: the named SingGuard policies the
20
+ stages share, moved from argus-curator's `guard.py` with their texts
21
+ unchanged. `POLICIES` / `PolicyName` (`default`, `adult`, `adult-granular`,
22
+ `adult-cautious`) and `policy_text(name)` (`None` for SingGuard's own rules).
23
+ - `illegal_rules(name)` / `is_illegal(name, rule)`: the rule letters whose
24
+ firing means illegal content, per policy. `adult-granular` is A, H and I;
25
+ `adult` and `adult-cautious` are A. `default` has none, because its A
26
+ covers consensual adult nudity too. B (crime) and G (animal abuse) are
27
+ never illegal.
28
+ - `mode(name)`: `sfw` for `default` and any `sfw-*` name (the prefix is
29
+ reserved for SFW policies added later), `nsfw` for every other policy.
30
+ - An unknown name raises `ModerationConfigError`.
31
+
32
+ ## [0.4.1] - 2026-10-27
33
+
34
+ ### Added
35
+ - `SingGuardClient(thinking="slow")` and `thinking="cascade"`: the argus-cull
36
+ `singguard` service's modes. `slow` reasons with no fast first-line verdict,
37
+ so the reasoning is not anchored to a verdict made before it; `cascade` runs
38
+ `fast` on every image and `slow` only on the ones fast flags, and slow's
39
+ verdict stands. `THINKING_MODES` lists them. A SingGuard server without a
40
+ mode answers HTTP 400, raised as `GuardCheckFailed` per check.
41
+ - `GuardResult.thinking`: the mode the server says it judged in (`None` from a
42
+ server that does not say). The argus-cull service can be forced server-side
43
+ to a mode other than the one asked for, and this records which it used.
44
+
10
45
  ## [0.4.0] - 2026-10-26
11
46
 
12
47
  ### Added
@@ -103,7 +138,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
103
138
  (`LocalBackend`, `RemoteBackend`, `RemoteProvider`, `with_retries`
104
139
  retrying only transient errors, `resolve_device`).
105
140
 
106
- [Unreleased]: https://github.com/smk762/argus-cortex/compare/v0.4.0...HEAD
141
+ [Unreleased]: https://github.com/smk762/argus-cortex/compare/v0.4.2...HEAD
142
+ [0.4.2]: https://github.com/smk762/argus-cortex/compare/v0.4.1...v0.4.2
143
+ [0.4.1]: https://github.com/smk762/argus-cortex/compare/v0.4.0...v0.4.1
107
144
  [0.4.0]: https://github.com/smk762/argus-cortex/compare/v0.3.0...v0.4.0
108
145
  [0.3.0]: https://github.com/smk762/argus-cortex/compare/v0.2.0...v0.3.0
109
146
  [0.2.0]: https://github.com/smk762/argus-cortex/compare/v0.1.0...v0.2.0
@@ -19,7 +19,7 @@ Because everything here is imported by the downstream stages, **a change ripples
19
19
  - `backends.py` — the AI-backend contract: `Backend`/`LocalBackend`/`RemoteBackend` (point at a hosted service via `from_host(host, port)` or `from_provider(RemoteProvider.…)`), plus `with_retries()` and `resolve_device()`. httpx is lazy, behind `[remote]`.
20
20
  - `server.py` — the shared FastAPI/ASGI scaffolding other repos' micro-servers mount: `WriteGuard` (pure-ASGI method gate), the `constant_refuse()` (read-only/replay) and `cross_site_refuse()` (cross-origin write) predicates, `env_flag()`, and the `UNSAFE_METHODS`/`TRUTHY`/`FALSY` constants. Starlette is behind `[server]`.
21
21
  - `xmp.py` — the suite's XMP sidecar read/merge/write (`write_xmp`/`read_xmp`/`merge_xmp`/`parse_xmp`, `XmpData`/`XmpRegions`, `XmpError`), used by lens (keywords/caption) and curator (faces, content rating). Stdlib `xml.etree` only. **Writes merge — latest wins per property, foreign properties preserved; keep that.** `None` = leave untouched, empty = remove. A DTD is refused and an unparseable sidecar raises rather than being overwritten. The `argus` namespace URI is a wire contract — don't change it.
22
- - `moderation/` — content-moderation clients over HTTP: `nudenet.py` (`NudeNetClient`, `NsfwResult`, `DEFAULT_EXPOSED_LABELS` — **the suite's one definition of nudity; don't widen it casually**, every stage's NSFW numbers move with it), `singguard.py` (`SingGuardClient`, `GuardResult`, `rule_matches`), `_http.py` (shared POST, proxy-free opener, `CheckFailed`/`ModerationConfigError`). Stdlib + pydantic; Pillow lazily, behind `[images]`. Never import NudeNet (AGPL) — HTTP only. URLs are passed in; stages own their env config.
22
+ - `moderation/` — content-moderation clients over HTTP: `nudenet.py` (`NudeNetClient`, `NsfwResult`, `DEFAULT_EXPOSED_LABELS` — **the suite's one definition of nudity; don't widen it casually**, every stage's NSFW numbers move with it), `singguard.py` (`SingGuardClient` incl. text-only `check_text()`, `GuardResult`, `rule_matches`), `policies.py` (the named policy texts, the per-policy illegal-rule table and `mode()`: `default`/`sfw-*` are SFW — **a policy's text and its illegal letters change together**), `_http.py` (shared POST, proxy-free opener, `CheckFailed`/`ModerationConfigError`). Stdlib + pydantic; Pillow lazily, behind `[images]`. Never import NudeNet (AGPL) — HTTP only. URLs are passed in; stages own their env config.
23
23
  - `store/` — the optional stateful-services layer, opt-in per `CORTEX_*` env var: `lineage.py` (Phase 1 Postgres DAG, `open_lineage_store()`), `vector.py` (Phase 2 Qdrant, `open_vector_store()`), `blob.py` (Phase 3 MinIO/S3, `open_blob_store()`), over `config.py`/`models.py`/`errors.py`. Each `open_*()` returns a `Null*` no-op when its URL is unset. Drivers are behind `[postgres]`/`[qdrant]`/`[s3]`.
24
24
 
25
25
  There is **no CLI and no committed schema in this repo** — the `schema`/`schema --check` pattern is something `wire.py` *enables* for downstream repos, not something cortex runs on itself.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: argus-cortex
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Shared foundation for the Argus suite: taxonomy, versioned wire-schema tooling, and the local/remote AI backend contract
5
5
  Project-URL: Repository, https://github.com/smk762/argus-cortex
6
6
  Author: smk762
@@ -99,11 +99,26 @@ result = nudenet.check(image_bytes, "beach.jpg")
99
99
  result.exposed_score # 0.0-1.0 over DEFAULT_EXPOSED_LABELS; result.labels = per-class maxima
100
100
 
101
101
  guard = SingGuardClient("http://guard:9150", policy=None) # None = SingGuard's default rules
102
+ # thinking="fast" (default) is a verdict and rule; "fast-slow" also writes why; "slow" writes why
103
+ # with no fast verdict ahead of the reasoning; "cascade" is fast, then slow only for what fast
104
+ # flagged (the last two: argus-cull's singguard service only).
102
105
  verdict = guard.check(image_bytes, text="the prompt, if any")
106
+ preflight = guard.check_text("the prompt") # no image: judge a prompt before it is run
103
107
  verdict.safe, verdict.rules # False, ["B. Real-World Crimes & Public Safety"]
104
108
  any(rule_matches(r, "B") for r in verdict.rules) # match a rule by letter or full name
105
109
  ```
106
110
 
111
+ ```python
112
+ from argus_cortex.moderation import SingGuardClient, policies
113
+
114
+ guard = SingGuardClient("http://guard:9150", policy=policies.policy_text("adult-granular"))
115
+ verdict = guard.check(image_bytes)
116
+ illegal = [r for r in verdict.rules if policies.is_illegal("adult-granular", r)] # A, H or I
117
+ policies.mode("adult-granular"), policies.mode("default") # "nsfw", "sfw"
118
+ ```
119
+
120
+ Stages ask for a policy by name; `policies` holds each one's text, the rules that mean illegal content under it, and its SFW/NSFW mode (`default` and `sfw-*` are SFW).
121
+
107
122
  Each `check()` raises `CheckFailed` (`NudeNetCheckFailed` / `GuardCheckFailed`) for a network error or an unusable answer, so a caller checking many images can record "unchecked" against the one image — and should treat unchecked as *not* safe. Bad configuration raises `ModerationConfigError`, whose message never echoes the URL.
108
123
 
109
124
  ```python
@@ -53,11 +53,26 @@ result = nudenet.check(image_bytes, "beach.jpg")
53
53
  result.exposed_score # 0.0-1.0 over DEFAULT_EXPOSED_LABELS; result.labels = per-class maxima
54
54
 
55
55
  guard = SingGuardClient("http://guard:9150", policy=None) # None = SingGuard's default rules
56
+ # thinking="fast" (default) is a verdict and rule; "fast-slow" also writes why; "slow" writes why
57
+ # with no fast verdict ahead of the reasoning; "cascade" is fast, then slow only for what fast
58
+ # flagged (the last two: argus-cull's singguard service only).
56
59
  verdict = guard.check(image_bytes, text="the prompt, if any")
60
+ preflight = guard.check_text("the prompt") # no image: judge a prompt before it is run
57
61
  verdict.safe, verdict.rules # False, ["B. Real-World Crimes & Public Safety"]
58
62
  any(rule_matches(r, "B") for r in verdict.rules) # match a rule by letter or full name
59
63
  ```
60
64
 
65
+ ```python
66
+ from argus_cortex.moderation import SingGuardClient, policies
67
+
68
+ guard = SingGuardClient("http://guard:9150", policy=policies.policy_text("adult-granular"))
69
+ verdict = guard.check(image_bytes)
70
+ illegal = [r for r in verdict.rules if policies.is_illegal("adult-granular", r)] # A, H or I
71
+ policies.mode("adult-granular"), policies.mode("default") # "nsfw", "sfw"
72
+ ```
73
+
74
+ Stages ask for a policy by name; `policies` holds each one's text, the rules that mean illegal content under it, and its SFW/NSFW mode (`default` and `sfw-*` are SFW).
75
+
61
76
  Each `check()` raises `CheckFailed` (`NudeNetCheckFailed` / `GuardCheckFailed`) for a network error or an unusable answer, so a caller checking many images can record "unchecked" against the one image — and should treat unchecked as *not* safe. Bad configuration raises `ModerationConfigError`, whose message never echoes the URL.
62
77
 
63
78
  ```python
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.4.2'
22
+ __version_tuple__ = version_tuple = (0, 4, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -20,6 +20,7 @@ from argus_cortex.moderation.nudenet import (
20
20
  exposed_score,
21
21
  )
22
22
  from argus_cortex.moderation.singguard import (
23
+ INPUT_ROLES,
23
24
  THINKING_MODES,
24
25
  GuardCheckFailed,
25
26
  GuardResult,
@@ -31,6 +32,7 @@ from argus_cortex.moderation.singguard import (
31
32
 
32
33
  __all__ = [
33
34
  "DEFAULT_EXPOSED_LABELS",
35
+ "INPUT_ROLES",
34
36
  "NUDENET_LABELS",
35
37
  "THINKING_MODES",
36
38
  "CheckFailed",
@@ -0,0 +1,186 @@
1
+ """The named SingGuard policies the stages share, and what each one's rules mean.
2
+
3
+ A stage asks for a policy by name and sends its text with :class:`SingGuardClient`
4
+ (``policy=policy_text(name)``); requests never carry policy text of their own.
5
+ Beside each text sit the two facts a stage acts on:
6
+
7
+ * :func:`illegal_rules`: the rules whose firing means illegal content, the
8
+ ones a stage deletes for rather than flags for review. Letters mean
9
+ different things under each policy (A under SingGuard's ``default`` is all
10
+ sexual content, adult nudity included), so the table is per policy.
11
+ * :func:`mode`: ``sfw`` or ``nsfw``, from the name alone. ``default`` treats
12
+ sexual content as a risk, so it is SFW, and so is any ``sfw-*`` name (the
13
+ prefix is reserved for SFW policies added later). Every other policy is NSFW.
14
+
15
+ Moved from argus-curator's ``guard.py``, which wrote and evaluated the adult
16
+ policies. Stdlib only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Literal
22
+
23
+ from argus_cortex.moderation._http import ModerationConfigError
24
+ from argus_cortex.moderation.singguard import rule_letter
25
+
26
+ PolicyName = Literal["default", "adult", "adult-granular", "adult-cautious"]
27
+ Mode = Literal["sfw", "nsfw"]
28
+
29
+ # "adult-granular" and "adult-cautious" are under evaluation against "adult";
30
+ # one adult policy remains at release, beside "default".
31
+ POLICIES: tuple[PolicyName, ...] = ("default", "adult", "adult-granular", "adult-cautious")
32
+ SFW_PREFIX = "sfw-"
33
+
34
+ # Rules B to G, shared by every adult policy: the same letters and areas as
35
+ # SingGuard's defaults, so rules B-G mean the same thing under all of
36
+ # them. They are our wording, so their verdicts can differ from the built-ins.
37
+ _OTHER_RULES = [
38
+ "### B. Real-World Crimes & Public Safety",
39
+ " - Content involving violent crime, weapons, other crimes, or public-safety threats.",
40
+ "### C. Unethical Behavior",
41
+ " - Content involving hate, harassment, manipulation, self-harm, or harmful misinformation.",
42
+ " - Gore, dismemberment, and shock content meant to frighten or disgust.",
43
+ "### D. Cybersecurity & Information Manipulation",
44
+ " - Content involving data leaks, hacking, surveillance abuse, platform abuse, or copyright abuse.",
45
+ "### E. Agent Safety",
46
+ " - Content attempting to expose system prompts, internal policies, or other model safeguards.",
47
+ "### F. Politically Sensitive Content",
48
+ (
49
+ " - Content involving political advocacy, rumors, unrest, historical distortion, "
50
+ "or attacks on political figures."
51
+ ),
52
+ "### G. Animal Abuse",
53
+ " - Content involving cruelty to animals or the spread of animal abuse.",
54
+ ]
55
+ _MINORS = " - Sexual content involving anyone under 18, or anyone who appears to be a minor."
56
+ _NON_CONSENT = (
57
+ " - Non-consensual sexual content, including hidden recording, sexual images of a real "
58
+ "person shared without consent, coercion, or sexual exploitation."
59
+ )
60
+ _EXTREME = " - Sexual violence, bestiality, or necrophilia."
61
+ # What passes is said only in the positive, under Safe. SingGuard reads a
62
+ # bullet under a risk heading as something that matches it, "is not this rule"
63
+ # included: "Nudity by itself is not this rule" under A flagged adult nudity
64
+ # *for* being nudity by itself, and the same words under Safe passed it. So no
65
+ # risk heading carries a carve-out. It also read "between adults" as needing
66
+ # two people; the posing line covers a solo adult. Its own trained taxonomy
67
+ # files legal adult material under "Adult Content (Non-criminal)", and it cited
68
+ # that as a violation under every adult policy, so Safe names it.
69
+ _ADULT_CONTENT_SAFE = (
70
+ ' - "Adult Content (Non-criminal)" is Safe under this policy: legal nudity and sexual content by adults, '
71
+ "alone or together."
72
+ )
73
+ _ADULT_SAFE = [
74
+ " - Content that does not match any risk category, including consensual adult nudity and sexual content.",
75
+ _ADULT_CONTENT_SAFE,
76
+ " - A single adult posing nude or in lingerie for the camera, including professional photo sets.",
77
+ (
78
+ " - Consent cannot be read from an image. A posed, camera-aware adult counts as consenting unless "
79
+ "hidden recording, restraint, coercion, or an unconscious person is shown."
80
+ ),
81
+ ]
82
+
83
+ # Sent in full: SingGuard reads a bare "nsfw" as rule text. One broad rule A
84
+ # holds every hard sexual case.
85
+ ADULT_POLICY = "\n".join(
86
+ [
87
+ "## Risk Categories",
88
+ "### A. Illegal or Extreme Sexual Content",
89
+ _MINORS,
90
+ _NON_CONSENT,
91
+ _EXTREME,
92
+ *_OTHER_RULES,
93
+ "### Safe",
94
+ *_ADULT_SAFE,
95
+ ]
96
+ )
97
+
98
+ # The same coverage with a letter per hard case (A minors, H non-consent,
99
+ # I sexual violence and extreme content), so the verdict itself says which
100
+ # hard rule fired instead of only the write-up. Narrower rules may also fire
101
+ # less often on doubtful cases; that is what the evaluation measures.
102
+ ADULT_GRANULAR_POLICY = "\n".join(
103
+ [
104
+ "## Risk Categories",
105
+ "### A. Minors",
106
+ _MINORS,
107
+ *_OTHER_RULES,
108
+ "### H. Non-Consensual Sexual Content",
109
+ _NON_CONSENT,
110
+ " - Sexual content showing someone asleep, unconscious, or otherwise unable to consent.",
111
+ "### I. Sexual Violence & Extreme Content",
112
+ _EXTREME,
113
+ "### Safe",
114
+ *_ADULT_SAFE,
115
+ ]
116
+ )
117
+
118
+ # ADULT_POLICY with doubt resolved towards rule A: an age or consent the image
119
+ # does not make clear counts as a hit. Trades false passes for false flags.
120
+ ADULT_CAUTIOUS_POLICY = "\n".join(
121
+ [
122
+ "## Risk Categories",
123
+ "### A. Illegal or Extreme Sexual Content",
124
+ _MINORS,
125
+ " - Nude or sexual content where any person's age is unclear and they could be under 18.",
126
+ _NON_CONSENT,
127
+ (
128
+ " - Nude or sexual content where consent is unclear, for example someone who seems "
129
+ "unaware of the camera, asleep, intoxicated, or restrained."
130
+ ),
131
+ _EXTREME,
132
+ *_OTHER_RULES,
133
+ "### Safe",
134
+ (
135
+ " - Content that does not match any risk category, including consensual adult nudity and "
136
+ "sexual content where every person clearly appears to be an adult."
137
+ ),
138
+ (
139
+ ' - "Adult Content (Non-criminal)" is Safe under this policy: legal nudity and sexual content '
140
+ "where every person clearly appears to be an adult."
141
+ ),
142
+ ]
143
+ )
144
+
145
+ _TEXT: dict[str, str | None] = {
146
+ "default": None, # SingGuard's own rules: nothing is sent
147
+ "adult": ADULT_POLICY,
148
+ "adult-granular": ADULT_GRANULAR_POLICY,
149
+ "adult-cautious": ADULT_CAUTIOUS_POLICY,
150
+ }
151
+
152
+ # Rule letters that mean illegal content. "default" has none: its A covers
153
+ # consensual adult nudity too, so it cannot tell illegal from adult. Crime (B)
154
+ # and animal abuse (G) depict, they are not illegal content, under every policy.
155
+ _ILLEGAL: dict[str, frozenset[str]] = {
156
+ "default": frozenset(),
157
+ "adult": frozenset({"A"}),
158
+ "adult-granular": frozenset({"A", "H", "I"}),
159
+ "adult-cautious": frozenset({"A"}),
160
+ }
161
+
162
+
163
+ def _known(name: str) -> str:
164
+ if name not in _TEXT:
165
+ raise ModerationConfigError(f"unknown SingGuard policy {name!r}; known: {', '.join(POLICIES)}")
166
+ return name
167
+
168
+
169
+ def policy_text(name: str) -> str | None:
170
+ """The text to send for policy *name*: ``None`` for ``default`` (SingGuard's own rules)."""
171
+ return _TEXT[_known(name)]
172
+
173
+
174
+ def illegal_rules(name: str) -> frozenset[str]:
175
+ """The rule letters under policy *name* whose firing means illegal content."""
176
+ return _ILLEGAL[_known(name)]
177
+
178
+
179
+ def is_illegal(name: str, rule: str) -> bool:
180
+ """*rule*, as a verdict reports it (``"H. Non-Consensual Sexual Content"`` or ``"H"``), is illegal under *name*."""
181
+ return rule_letter(rule).removesuffix(".").upper() in illegal_rules(name)
182
+
183
+
184
+ def mode(name: str) -> Mode:
185
+ """``sfw`` for ``default`` and any ``sfw-*`` name, else ``nsfw``. By name, so it needs no table entry."""
186
+ return "sfw" if name == "default" or name.startswith(SFW_PREFIX) else "nsfw"
@@ -8,8 +8,19 @@ gore), D. Cybersecurity, E. Agent Safety, F. Politically Sensitive and G.
8
8
  Animal Abuse. A custom policy text replaces them. It gives no scores.
9
9
 
10
10
  API: ``POST /api/guard/check`` with JSON ``{input, image_base64, policy,
11
- thinking}``, answered by ``{safe, verdict, rule, explanation, categories:
12
- [{code, label, why}], model, elapsed_ms}``.
11
+ thinking}`` (or ``{input, input_role, policy, thinking}`` for a text-only
12
+ check, which not every server takes), answered by ``{safe, verdict, rule, explanation, categories:
13
+ [{code, label, why}], model, elapsed_ms}`` and, from the argus-cull
14
+ ``singguard`` service, ``thinking``: the mode the server judged in, which
15
+ its ``SINGGUARD_FORCE_THINKING`` can make differ from the one asked for.
16
+
17
+ ``thinking`` is ``fast`` (a verdict and rule), ``fast-slow`` (SingGuard's own
18
+ reasoning mode: a fast first-line verdict, then the reasoning and a final
19
+ answer), ``slow`` (the argus-cull service's variant with no first-line
20
+ verdict, so the reasoning is not anchored to a verdict made before it) or
21
+ ``cascade`` (that service's ``fast`` for every image and ``slow`` for the ones
22
+ fast flags, slow's verdict standing). Servers without ``slow`` and ``cascade``
23
+ answer HTTP 400 to them.
13
24
 
14
25
  Large images are downscaled before sending (Pillow, from the ``[images]``
15
26
  extra, imported only when downscaling is on).
@@ -27,7 +38,9 @@ from pydantic import BaseModel, Field
27
38
 
28
39
  from argus_cortex.moderation._http import CheckFailed, ModerationConfigError, post_json, service_url
29
40
 
30
- THINKING_MODES = ("fast", "fast-slow")
41
+ THINKING_MODES = ("fast", "fast-slow", "slow", "cascade")
42
+ # Who wrote the text of a text-only check: the person asking, or a model answering.
43
+ INPUT_ROLES = ("user", "assistant")
31
44
  # The guard model sees far fewer pixels than a camera original, and base64
32
45
  # JSON inflates every byte by 4/3.
33
46
  MAX_EDGE = 1536
@@ -51,6 +64,9 @@ class GuardResult(BaseModel):
51
64
  # own rules) or "custom-<sha256 prefix>" of the policy text.
52
65
  model: str | None = None
53
66
  policy: str = "default"
67
+ # The thinking mode the server judged in, when it says (the argus-cull
68
+ # service does; it can be forced server-side to differ from the request).
69
+ thinking: str | None = None
54
70
 
55
71
 
56
72
  class GuardCheckFailed(CheckFailed):
@@ -104,6 +120,7 @@ def to_result(payload: object, policy: str = "default") -> GuardResult:
104
120
  explanation=_clip(payload.get("explanation"), _EXPLANATION_MAX),
105
121
  model=_clip(payload.get("model"), _FIELD_MAX),
106
122
  policy=policy,
123
+ thinking=_clip(payload.get("thinking"), _FIELD_MAX),
107
124
  )
108
125
 
109
126
 
@@ -154,7 +171,10 @@ class SingGuardClient:
154
171
  """A SingGuard server at *base_url* (its origin, e.g. ``http://guard:9150``).
155
172
 
156
173
  *policy* is the policy text (``None`` = SingGuard's default rules);
157
- *thinking* is ``fast`` (verdict and rule) or ``fast-slow`` (also writes why).
174
+ *thinking* is one of :data:`THINKING_MODES`: ``fast`` (verdict and rule),
175
+ ``fast-slow`` (also writes why), ``slow`` (writes why, with no fast
176
+ verdict ahead of the reasoning) or ``cascade`` (fast, then slow only for
177
+ what fast flagged); the last two need the argus-cull ``singguard`` service.
158
178
  With *downscale* on (the default, needs ``[images]``), images over
159
179
  :data:`MAX_EDGE` px or :data:`MAX_BYTES` are shrunk before sending.
160
180
  Thread-safe: :meth:`check` holds no state.
@@ -188,11 +208,23 @@ class SingGuardClient:
188
208
  Raises :class:`GuardCheckFailed`.
189
209
  """
190
210
  image = downscale(data) if self.downscale else data
191
- body: dict[str, str] = {
192
- "input": text,
193
- "image_base64": base64.b64encode(image).decode("ascii"),
194
- "thinking": self.thinking,
195
- }
211
+ return self._post({"input": text, "image_base64": base64.b64encode(image).decode("ascii")})
212
+
213
+ def check_text(self, text: str, role: str = "user") -> GuardResult:
214
+ """Judge *text* with no image: a prompt before it is run (``role="user"``), or a model's reply
215
+ (``"assistant"``).
216
+
217
+ Needs a server that takes a check without ``image_base64``: agent-composer's guard router
218
+ does; argus-cull's ``singguard`` service answers HTTP 422, raised as :class:`GuardCheckFailed`.
219
+ """
220
+ if role not in INPUT_ROLES:
221
+ raise ValueError(f"role must be one of {', '.join(INPUT_ROLES)}")
222
+ if not text.strip():
223
+ raise ValueError("there is no text to check")
224
+ return self._post({"input": text, "input_role": role})
225
+
226
+ def _post(self, body: dict[str, str]) -> GuardResult:
227
+ body["thinking"] = self.thinking
196
228
  if self.policy is not None:
197
229
  body["policy"] = self.policy
198
230
  payload = post_json(
@@ -102,20 +102,25 @@ class _NudeNet(_Fake):
102
102
 
103
103
 
104
104
  class _SingGuard(_Fake):
105
- answers: dict[int, dict] = {}
105
+ answers: dict[int, dict] = {} # a test's overrides of _GUARD_ANSWERS, by red value
106
106
 
107
107
  def do_POST(self) -> None:
108
108
  body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
109
- image = Image.open(io.BytesIO(base64.b64decode(body["image_base64"])))
110
- red = image.convert("RGB").getpixel((0, 0))[0]
109
+ if "image_base64" in body:
110
+ image = Image.open(io.BytesIO(base64.b64decode(body["image_base64"])))
111
+ red = image.convert("RGB").getpixel((0, 0))[0]
112
+ size, fmt = image.size, image.format
113
+ else: # a text-only check: the text picks the answer
114
+ red, size, fmt = _TEXT_ANSWERS.get(body["input"], 10), None, None
111
115
  type(self).seen.append(
112
- {"path": self.path, "size": image.size, "format": image.format,
116
+ {"path": self.path, "size": size, "format": fmt,
113
117
  **{k: v for k, v in body.items() if k != "image_base64"}}
114
118
  ) # fmt: skip
115
119
  if red == _FAIL:
116
120
  self._send(500)
117
121
  else:
118
- self._send(200, {**_GUARD_ANSWERS.get(red, _GUARD_ANSWERS[10]), "model": "SingGuard-4b", "elapsed_ms": 5})
122
+ answer = self.answers.get(red) or _GUARD_ANSWERS.get(red, _GUARD_ANSWERS[10])
123
+ self._send(200, {**answer, "model": "SingGuard-4b", "elapsed_ms": 5})
119
124
 
120
125
 
121
126
  def _serve(handler: type[_Fake]) -> Iterator[tuple[type[_Fake], str]]:
@@ -229,6 +234,7 @@ _GUARD_ANSWERS: dict[int, dict] = {
229
234
  30: {"safe": False, "verdict": "unsafe", "rule": _GORE, "explanation": "", "categories": 5}, # malformed
230
235
  40: {"verdict": "safe"}, # no boolean verdict: a failed check, not a pass
231
236
  }
237
+ _TEXT_ANSWERS = {"rob a bank": 20, "fail": _FAIL} # a text-only check's input -> the red value it answers as
232
238
 
233
239
 
234
240
  def test_singguard_config() -> None:
@@ -239,7 +245,8 @@ def test_singguard_config() -> None:
239
245
  assert custom.policy == "### A. Minors" and custom.policy_name == policy_name("### A. Minors")
240
246
  assert custom.policy_name == policy_name(" ### A. Minors\n") # a policy read from a file matches
241
247
  assert custom.policy_name.startswith("custom-") and len(custom.policy_name) == len("custom-") + 12
242
- for kwargs in ({"thinking": "slow"}, {"policy": " \n"}):
248
+ assert [SingGuardClient("http://g", thinking=m).thinking for m in ("slow", "cascade")] == ["slow", "cascade"]
249
+ for kwargs in ({"thinking": "slower"}, {"thinking": "FAST"}, {"policy": " \n"}):
243
250
  with pytest.raises(ModerationConfigError):
244
251
  SingGuardClient("http://g", **kwargs)
245
252
  with pytest.raises(ModerationConfigError):
@@ -263,12 +270,44 @@ def test_singguard_check(singguard_server) -> None:
263
270
  assert {s["thinking"] for s in fake.seen} == {"fast-slow"} and {s["policy"] for s in fake.seen} == {"### A. Minors"}
264
271
 
265
272
 
273
+ def test_singguard_check_text(singguard_server) -> None:
274
+ """A text-only check sends no image, says whose text it is, and keeps the client's policy and mode."""
275
+ fake, url = singguard_server
276
+ client = SingGuardClient(url, timeout=5, thinking="cascade", policy="### A. Minors")
277
+ assert client.check_text("a cat on a sofa").safe
278
+ assert client.check_text("rob a bank", role="assistant").rules == [_CRIME, _GORE]
279
+ with pytest.raises(GuardCheckFailed, match="HTTP 500"):
280
+ client.check_text("fail")
281
+ assert all("image_base64" not in s and s["size"] is None for s in fake.seen)
282
+ assert [s["input_role"] for s in fake.seen] == ["user", "assistant", "user"]
283
+ assert {s["thinking"] for s in fake.seen} == {"cascade"} and {s["policy"] for s in fake.seen} == {"### A. Minors"}
284
+ for text, role in (("", "user"), (" \n", "user"), ("hi", "system")):
285
+ with pytest.raises(ValueError):
286
+ client.check_text(text, role=role)
287
+ assert len(fake.seen) == 3 # a bad argument never reaches the server
288
+
289
+
266
290
  def test_singguard_default_policy_is_not_sent(singguard_server) -> None:
267
291
  fake, url = singguard_server
268
292
  SingGuardClient(url).check(_png(10))
269
293
  assert "policy" not in fake.seen[0]
270
294
 
271
295
 
296
+ def test_singguard_slow_is_sent_and_the_mode_used_is_recorded(singguard_server) -> None:
297
+ """The argus-cull service answers with the mode it judged in, which a
298
+ server-side force can make differ from the request; it is recorded as sent.
299
+ A server that says nothing leaves it None."""
300
+ fake, url = singguard_server
301
+ client = SingGuardClient(url, timeout=5, thinking="slow")
302
+ assert client.check(_png(10)).thinking is None
303
+ fake.answers = {10: {**_GUARD_ANSWERS[10], "thinking": "fast-slow"}}
304
+ try:
305
+ assert client.check(_png(10)).thinking == "fast-slow"
306
+ finally:
307
+ fake.answers = {}
308
+ assert {s["thinking"] for s in fake.seen} == {"slow"}
309
+
310
+
272
311
  def test_singguard_downscales_large_images(singguard_server) -> None:
273
312
  fake, url = singguard_server
274
313
  SingGuardClient(url).check(_png(10, (4000, 3000)))
@@ -0,0 +1,54 @@
1
+ """The named SingGuard policies: their texts, illegal rules and modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from argus_cortex.moderation import ModerationConfigError, SingGuardClient, policies, policy_name
8
+
9
+
10
+ def test_every_policy_has_a_text_and_an_illegal_table() -> None:
11
+ assert policies.POLICIES == ("default", "adult", "adult-granular", "adult-cautious")
12
+ assert policies.policy_text("default") is None # SingGuard's own rules: nothing is sent
13
+ for name in policies.POLICIES[1:]:
14
+ text = policies.policy_text(name)
15
+ assert text.startswith("## Risk Categories\n") and "### Safe" in text
16
+ # The client sends it as written, so a recorded policy name is stable.
17
+ assert SingGuardClient("http://g", policy=text, downscale=False).policy_name == policy_name(text)
18
+ assert len({policies.policy_text(n) for n in policies.POLICIES}) == len(policies.POLICIES)
19
+
20
+
21
+ def test_illegal_rules_are_per_policy() -> None:
22
+ assert policies.illegal_rules("default") == frozenset() # its A is all sexual content, adult included
23
+ assert policies.illegal_rules("adult-granular") == {"A", "H", "I"}
24
+ assert policies.illegal_rules("adult") == policies.illegal_rules("adult-cautious") == {"A"}
25
+ # Each illegal letter is a heading in that policy's own text.
26
+ for name in policies.POLICIES[1:]:
27
+ for letter in policies.illegal_rules(name):
28
+ assert f"\n### {letter}. " in policies.policy_text(name)
29
+
30
+
31
+ def test_is_illegal_reads_a_rule_as_verdicts_report_it() -> None:
32
+ assert policies.is_illegal("adult-granular", "H. Non-Consensual Sexual Content")
33
+ assert policies.is_illegal("adult-granular", "I") and policies.is_illegal("adult-granular", "a.")
34
+ assert policies.is_illegal("adult", "A. Illegal or Extreme Sexual Content")
35
+ assert not policies.is_illegal("default", "A. Sexual Content Risk")
36
+ for name in policies.POLICIES: # crime and animal abuse go to review, never deletion
37
+ assert not policies.is_illegal(name, "B. Real-World Crimes & Public Safety")
38
+ assert not policies.is_illegal(name, "G. Animal Abuse")
39
+ assert not policies.is_illegal(name, "Safe")
40
+
41
+
42
+ def test_mode_is_by_name() -> None:
43
+ assert policies.mode("default") == "sfw"
44
+ assert policies.mode("sfw-strict") == "sfw" # a later SFW policy needs no table entry
45
+ assert [policies.mode(n) for n in policies.POLICIES[1:]] == ["nsfw"] * 3
46
+ assert policies.mode("defaults") == "nsfw"
47
+
48
+
49
+ def test_unknown_policy_names_are_refused() -> None:
50
+ for call in (policies.policy_text, policies.illegal_rules):
51
+ with pytest.raises(ModerationConfigError, match="unknown SingGuard policy"):
52
+ call("adult-lax")
53
+ with pytest.raises(ModerationConfigError):
54
+ policies.is_illegal("adult-lax", "A")
File without changes
File without changes
File without changes
File without changes