argus-cortex 0.4.3__tar.gz → 0.4.5__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.
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/CHANGELOG.md +34 -1
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/PKG-INFO +1 -1
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/_version.py +2 -2
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/moderation/policies.py +78 -18
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/moderation/singguard.py +3 -1
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/lineage.py +15 -5
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_moderation.py +18 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_policies.py +35 -3
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_store.py +5 -3
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_store_pg_integration.py +14 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/.copier-answers.yml +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/.env.example +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/.github/workflows/ci.yml +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/.github/workflows/release.yml +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/.gitignore +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/CLAUDE.md +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/LICENSE +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/Makefile +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/README.md +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/pyproject.toml +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/__init__.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/backends.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/gpu.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/moderation/__init__.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/moderation/_http.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/moderation/nudenet.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/py.typed +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/server.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/__init__.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/blob.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/config.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/errors.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/models.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/schema.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/store/vector.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/taxonomy.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/wire.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/src/argus_cortex/xmp.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_backends.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_blob.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_gpu.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_server.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_smoke.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_taxonomy.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_vector.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_wire.py +0 -0
- {argus_cortex-0.4.3 → argus_cortex-0.4.5}/tests/test_xmp.py +0 -0
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.5] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`adult-cinematic`**: `adult` with B narrowed to how-to / real targeting, and
|
|
14
|
+
Safe naming fictional crime (heist, fraud, dirty cops, war as setting, weapons
|
|
15
|
+
as a prop, drugs as a plot). Legal adult entertainment stays Safe the same
|
|
16
|
+
way as under `adult` (commercial porn, studio stills, an adult-site watermark).
|
|
17
|
+
Under evaluation against `adult` (#24). Letters and illegal A are unchanged.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Adult policies no longer invite a "closest" H/I dump-bin.** Safe now names
|
|
21
|
+
legal adult entertainment, commercial porn, explicit sex, genitalia, a
|
|
22
|
+
suggestive pose, a commercial or studio set, and an adult-site watermark, and
|
|
23
|
+
says that if no risk bullet matches the verdict is Safe. Headings dropped the
|
|
24
|
+
word "Extreme" (`A. Illegal Sexual Content`, granular `I. Sexual Violence`),
|
|
25
|
+
which the model read as a bin for legal porn. Letters are unchanged.
|
|
26
|
+
|
|
27
|
+
## [0.4.4] - 2026-10-31
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **`SingGuardClient` keeps a longer writeup.** `to_result` clipped `explanation` at 4000
|
|
31
|
+
characters, which cut cascade/slow reasoning mid-sentence. The cap is now 16384, enough
|
|
32
|
+
for a 2048-token slow writeup plus a cascade preface.
|
|
33
|
+
- **Re-recording an asset no longer erases what another stage recorded on it.** `record_asset`'s
|
|
34
|
+
upsert on `sha256` replaced `immich_id` and `metadata` outright, so argus-lens recording a caption
|
|
35
|
+
for an image argus-quarry had already recorded (licence, author, source) wiped that provenance,
|
|
36
|
+
and a call without an `immich_id` cleared the stored one. `metadata` now merges key by key
|
|
37
|
+
(`jsonb ||`: the new call's keys win, others are kept) and a `NULL` `immich_id` keeps the stored
|
|
38
|
+
one. `uri` is still last-write-wins: it is the asset's latest location. A caller that needs to
|
|
39
|
+
remove a metadata key can no longer do it through `record_asset`.
|
|
40
|
+
|
|
10
41
|
## [0.4.3] - 2026-10-30
|
|
11
42
|
|
|
12
43
|
### Added
|
|
@@ -164,7 +195,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
164
195
|
(`LocalBackend`, `RemoteBackend`, `RemoteProvider`, `with_retries`
|
|
165
196
|
retrying only transient errors, `resolve_device`).
|
|
166
197
|
|
|
167
|
-
[Unreleased]: https://github.com/smk762/argus-cortex/compare/v0.4.
|
|
198
|
+
[Unreleased]: https://github.com/smk762/argus-cortex/compare/v0.4.5...HEAD
|
|
199
|
+
[0.4.5]: https://github.com/smk762/argus-cortex/compare/v0.4.4...v0.4.5
|
|
200
|
+
[0.4.4]: https://github.com/smk762/argus-cortex/compare/v0.4.3...v0.4.4
|
|
168
201
|
[0.4.3]: https://github.com/smk762/argus-cortex/compare/v0.4.2...v0.4.3
|
|
169
202
|
[0.4.2]: https://github.com/smk762/argus-cortex/compare/v0.4.1...v0.4.2
|
|
170
203
|
[0.4.1]: https://github.com/smk762/argus-cortex/compare/v0.4.0...v0.4.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: argus-cortex
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.5
|
|
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
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 4,
|
|
21
|
+
__version__ = version = '0.4.5'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 5)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -23,20 +23,36 @@ from typing import Literal
|
|
|
23
23
|
from argus_cortex.moderation._http import ModerationConfigError
|
|
24
24
|
from argus_cortex.moderation.singguard import rule_letter
|
|
25
25
|
|
|
26
|
-
PolicyName = Literal["default", "adult", "adult-granular", "adult-cautious"]
|
|
26
|
+
PolicyName = Literal["default", "adult", "adult-granular", "adult-cautious", "adult-cinematic"]
|
|
27
27
|
Mode = Literal["sfw", "nsfw"]
|
|
28
28
|
|
|
29
|
-
# "adult-granular" and "adult-
|
|
30
|
-
# one adult policy remains at release, beside "default".
|
|
31
|
-
POLICIES: tuple[PolicyName, ...] = (
|
|
29
|
+
# "adult-granular", "adult-cautious" and "adult-cinematic" are under evaluation
|
|
30
|
+
# against "adult"; one adult policy remains at release, beside "default".
|
|
31
|
+
POLICIES: tuple[PolicyName, ...] = (
|
|
32
|
+
"default",
|
|
33
|
+
"adult",
|
|
34
|
+
"adult-granular",
|
|
35
|
+
"adult-cautious",
|
|
36
|
+
"adult-cinematic",
|
|
37
|
+
)
|
|
32
38
|
SFW_PREFIX = "sfw-"
|
|
33
39
|
|
|
34
|
-
# Rules B to G, shared by every adult policy: the same
|
|
35
|
-
# SingGuard's defaults
|
|
36
|
-
#
|
|
37
|
-
|
|
40
|
+
# Rules B to G, shared by every adult policy except cinematic B: the same
|
|
41
|
+
# letters and areas as SingGuard's defaults. They are our wording, so their
|
|
42
|
+
# verdicts can differ from the built-ins.
|
|
43
|
+
_CRIME_B = [
|
|
38
44
|
"### B. Real-World Crimes & Public Safety",
|
|
39
45
|
" - Content involving violent crime, weapons, other crimes, or public-safety threats.",
|
|
46
|
+
]
|
|
47
|
+
# Depiction is not a hit; how-to, a named real target, and operational threats are.
|
|
48
|
+
_CRIME_B_CINEMATIC = [
|
|
49
|
+
"### B. Real-World Crimes & Public Safety",
|
|
50
|
+
(
|
|
51
|
+
" - Instructional or operational assistance for a real-world crime: how to commit one, "
|
|
52
|
+
"targeting a named real person or a real institution, or an operational public-safety threat."
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
_OTHER_AFTER_B = [
|
|
40
56
|
"### C. Unethical Behavior",
|
|
41
57
|
" - Content involving hate, harassment, manipulation, self-harm, or harmful misinformation.",
|
|
42
58
|
" - Gore, dismemberment, and shock content meant to frighten or disgust.",
|
|
@@ -52,6 +68,7 @@ _OTHER_RULES = [
|
|
|
52
68
|
"### G. Animal Abuse",
|
|
53
69
|
" - Content involving cruelty to animals or the spread of animal abuse.",
|
|
54
70
|
]
|
|
71
|
+
_OTHER_RULES = [*_CRIME_B, *_OTHER_AFTER_B]
|
|
55
72
|
_MINORS = " - Sexual content involving anyone under 18, or anyone who appears to be a minor."
|
|
56
73
|
_NON_CONSENT = (
|
|
57
74
|
" - Non-consensual sexual content, including hidden recording, sexual images of a real "
|
|
@@ -66,9 +83,13 @@ _EXTREME = " - Sexual violence, bestiality, or necrophilia."
|
|
|
66
83
|
# two people; the posing line covers a solo adult. Its own trained taxonomy
|
|
67
84
|
# files legal adult material under "Adult Content (Non-criminal)", and it cited
|
|
68
85
|
# that as a violation under every adult policy, so Safe names it.
|
|
86
|
+
# Headings must not say "Extreme Content": the model treated explicit adult
|
|
87
|
+
# stills as I (or A) because that word was the closest dump bin, then picked
|
|
88
|
+
# H as "closest" even while saying the frame was consensual.
|
|
69
89
|
_ADULT_CONTENT_SAFE = (
|
|
70
|
-
' - "Adult Content (Non-criminal)" is Safe under this policy: legal
|
|
71
|
-
"alone or together
|
|
90
|
+
' - "Adult Content (Non-criminal)" is Safe under this policy: legal adult entertainment, '
|
|
91
|
+
"legal nudity and sexual content by adults, alone or together, including commercial porn, "
|
|
92
|
+
"explicit sex, genitalia, a suggestive pose, a commercial or studio set, and an adult-site watermark."
|
|
72
93
|
)
|
|
73
94
|
_ADULT_SAFE = [
|
|
74
95
|
" - Content that does not match any risk category, including consensual adult nudity and sexual content.",
|
|
@@ -78,6 +99,10 @@ _ADULT_SAFE = [
|
|
|
78
99
|
" - Consent cannot be read from an image. A posed, camera-aware adult counts as consenting unless "
|
|
79
100
|
"hidden recording, restraint, coercion, or an unconscious person is shown."
|
|
80
101
|
),
|
|
102
|
+
(
|
|
103
|
+
" - If no risk-category bullet matches, the verdict is Safe. Do not assign a risk letter "
|
|
104
|
+
"because it is the closest heading."
|
|
105
|
+
),
|
|
81
106
|
]
|
|
82
107
|
|
|
83
108
|
# Sent in full: SingGuard reads a bare "nsfw" as rule text. One broad rule A
|
|
@@ -85,7 +110,7 @@ _ADULT_SAFE = [
|
|
|
85
110
|
ADULT_POLICY = "\n".join(
|
|
86
111
|
[
|
|
87
112
|
"## Risk Categories",
|
|
88
|
-
"### A. Illegal
|
|
113
|
+
"### A. Illegal Sexual Content",
|
|
89
114
|
_MINORS,
|
|
90
115
|
_NON_CONSENT,
|
|
91
116
|
_EXTREME,
|
|
@@ -96,9 +121,9 @@ ADULT_POLICY = "\n".join(
|
|
|
96
121
|
)
|
|
97
122
|
|
|
98
123
|
# The same coverage with a letter per hard case (A minors, H non-consent,
|
|
99
|
-
# I sexual violence
|
|
100
|
-
#
|
|
101
|
-
#
|
|
124
|
+
# I sexual violence), so the verdict itself says which hard rule fired
|
|
125
|
+
# instead of only the write-up. Narrower rules may also fire less often on
|
|
126
|
+
# doubtful cases; that is what the evaluation measures.
|
|
102
127
|
ADULT_GRANULAR_POLICY = "\n".join(
|
|
103
128
|
[
|
|
104
129
|
"## Risk Categories",
|
|
@@ -108,7 +133,7 @@ ADULT_GRANULAR_POLICY = "\n".join(
|
|
|
108
133
|
"### H. Non-Consensual Sexual Content",
|
|
109
134
|
_NON_CONSENT,
|
|
110
135
|
" - Sexual content showing someone asleep, unconscious, or otherwise unable to consent.",
|
|
111
|
-
"### I. Sexual Violence
|
|
136
|
+
"### I. Sexual Violence",
|
|
112
137
|
_EXTREME,
|
|
113
138
|
"### Safe",
|
|
114
139
|
*_ADULT_SAFE,
|
|
@@ -120,7 +145,7 @@ ADULT_GRANULAR_POLICY = "\n".join(
|
|
|
120
145
|
ADULT_CAUTIOUS_POLICY = "\n".join(
|
|
121
146
|
[
|
|
122
147
|
"## Risk Categories",
|
|
123
|
-
"### A. Illegal
|
|
148
|
+
"### A. Illegal Sexual Content",
|
|
124
149
|
_MINORS,
|
|
125
150
|
" - Nude or sexual content where any person's age is unclear and they could be under 18.",
|
|
126
151
|
_NON_CONSENT,
|
|
@@ -136,8 +161,41 @@ ADULT_CAUTIOUS_POLICY = "\n".join(
|
|
|
136
161
|
"sexual content where every person clearly appears to be an adult."
|
|
137
162
|
),
|
|
138
163
|
(
|
|
139
|
-
' - "Adult Content (Non-criminal)" is Safe under this policy: legal
|
|
140
|
-
"where every person clearly appears to be an adult
|
|
164
|
+
' - "Adult Content (Non-criminal)" is Safe under this policy: legal adult entertainment '
|
|
165
|
+
"and legal nudity and sexual content where every person clearly appears to be an adult, "
|
|
166
|
+
"including commercial porn, explicit sex, genitalia, a suggestive pose, a commercial "
|
|
167
|
+
"or studio set, and an adult-site watermark."
|
|
168
|
+
),
|
|
169
|
+
(
|
|
170
|
+
" - If no risk-category bullet matches, the verdict is Safe. Do not assign a risk letter "
|
|
171
|
+
"because it is the closest heading."
|
|
172
|
+
),
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# ADULT_POLICY with B narrowed to how-to / real targeting, and cinematic
|
|
177
|
+
# crime named under Safe. Adult entertainment stays Safe the same way as
|
|
178
|
+
# under adult. Compared against adult; do not fold this into granular
|
|
179
|
+
# (that mix of lettering and B permissiveness is issue #24).
|
|
180
|
+
ADULT_CINEMATIC_POLICY = "\n".join(
|
|
181
|
+
[
|
|
182
|
+
"## Risk Categories",
|
|
183
|
+
"### A. Illegal Sexual Content",
|
|
184
|
+
_MINORS,
|
|
185
|
+
_NON_CONSENT,
|
|
186
|
+
_EXTREME,
|
|
187
|
+
*_CRIME_B_CINEMATIC,
|
|
188
|
+
*_OTHER_AFTER_B,
|
|
189
|
+
"### Safe",
|
|
190
|
+
*_ADULT_SAFE,
|
|
191
|
+
(
|
|
192
|
+
" - Legal adult entertainment is Safe under this policy, the same as under adult: "
|
|
193
|
+
"commercial porn, studio stills, explicit sex, and an adult-site watermark."
|
|
194
|
+
),
|
|
195
|
+
(
|
|
196
|
+
" - Fictional or cinematic crime as a story or still is Safe: a bank robbery, a heist, "
|
|
197
|
+
"fraud such as stealing pensions, institutional corruption such as dirty cops, "
|
|
198
|
+
"non-sexual violence, weapons shown as a prop, drugs as a plot, and war as a setting."
|
|
141
199
|
),
|
|
142
200
|
]
|
|
143
201
|
)
|
|
@@ -147,6 +205,7 @@ _TEXT: dict[str, str | None] = {
|
|
|
147
205
|
"adult": ADULT_POLICY,
|
|
148
206
|
"adult-granular": ADULT_GRANULAR_POLICY,
|
|
149
207
|
"adult-cautious": ADULT_CAUTIOUS_POLICY,
|
|
208
|
+
"adult-cinematic": ADULT_CINEMATIC_POLICY,
|
|
150
209
|
}
|
|
151
210
|
|
|
152
211
|
# Rule letters that mean illegal content. "default" has none: its A covers
|
|
@@ -157,6 +216,7 @@ _ILLEGAL: dict[str, frozenset[str]] = {
|
|
|
157
216
|
"adult": frozenset({"A"}),
|
|
158
217
|
"adult-granular": frozenset({"A", "H", "I"}),
|
|
159
218
|
"adult-cautious": frozenset({"A"}),
|
|
219
|
+
"adult-cinematic": frozenset({"A"}),
|
|
160
220
|
}
|
|
161
221
|
|
|
162
222
|
|
|
@@ -47,7 +47,9 @@ MAX_EDGE = 1536
|
|
|
47
47
|
MAX_BYTES = 4 * 1024 * 1024
|
|
48
48
|
_MIN_EDGE = 256 # below this, give up rather than send SingGuard a postage stamp
|
|
49
49
|
_NEEDS_PILLOW = "downscaling images needs: pip install argus-cortex[images] (or pass downscale=False)"
|
|
50
|
-
|
|
50
|
+
# Fits a 2048-token slow writeup plus a short cascade preface. 4000
|
|
51
|
+
# cut recorded reasoning mid-sentence on cascade scans.
|
|
52
|
+
_EXPLANATION_MAX = 16384
|
|
51
53
|
_FIELD_MAX = 200
|
|
52
54
|
|
|
53
55
|
|
|
@@ -70,7 +70,13 @@ class LineageStore(Protocol):
|
|
|
70
70
|
...
|
|
71
71
|
|
|
72
72
|
def record_asset(self, asset: SourceAsset) -> str | None:
|
|
73
|
-
"""Insert (or reuse, by ``sha256``) a source asset; return its id.
|
|
73
|
+
"""Insert (or reuse, by ``sha256``) a source asset; return its id.
|
|
74
|
+
|
|
75
|
+
On reuse, ``uri`` becomes this call's; ``metadata`` merges into the
|
|
76
|
+
stored bag (this call's keys win); a ``None`` ``immich_id`` keeps the
|
|
77
|
+
stored one. So stages recording the same bytes add to the row rather
|
|
78
|
+
than erase each other's fields.
|
|
79
|
+
"""
|
|
74
80
|
...
|
|
75
81
|
|
|
76
82
|
def record_caption(self, asset_id: str, caption: Caption) -> str | None:
|
|
@@ -302,8 +308,12 @@ class PostgresLineageStore:
|
|
|
302
308
|
|
|
303
309
|
def record_asset(self, asset: SourceAsset) -> str | None:
|
|
304
310
|
# De-dupe on content hash: re-ingesting identical bytes returns the
|
|
305
|
-
# existing row (DO UPDATE ensures RETURNING fires even on conflict)
|
|
306
|
-
#
|
|
311
|
+
# existing row (DO UPDATE ensures RETURNING fires even on conflict).
|
|
312
|
+
# Several stages record the same asset (quarry with provenance, lens
|
|
313
|
+
# with a caption), so a later call must not erase what an earlier one
|
|
314
|
+
# knew: uri is the latest location (last-write-wins), immich_id keeps
|
|
315
|
+
# the stored id when this call has none, and metadata merges key by
|
|
316
|
+
# key (jsonb ||: this call's keys win, the others are kept).
|
|
307
317
|
# NOTE: dedup only works when sha256 is set — NULLs are distinct under the
|
|
308
318
|
# UNIQUE index, so a hash-less asset inserts a fresh row every call.
|
|
309
319
|
return self._insert(
|
|
@@ -312,8 +322,8 @@ class PostgresLineageStore:
|
|
|
312
322
|
VALUES (%s, %s, %s, %s::jsonb)
|
|
313
323
|
ON CONFLICT (sha256) DO UPDATE
|
|
314
324
|
SET uri = EXCLUDED.uri,
|
|
315
|
-
immich_id = EXCLUDED.immich_id,
|
|
316
|
-
metadata = EXCLUDED.metadata
|
|
325
|
+
immich_id = COALESCE(EXCLUDED.immich_id, source_asset.immich_id),
|
|
326
|
+
metadata = source_asset.metadata || EXCLUDED.metadata
|
|
317
327
|
RETURNING id
|
|
318
328
|
""",
|
|
319
329
|
(asset.uri, asset.sha256, asset.immich_id, self._json(asset.metadata)),
|
|
@@ -253,6 +253,24 @@ def test_singguard_config() -> None:
|
|
|
253
253
|
SingGuardClient("gopher://g")
|
|
254
254
|
|
|
255
255
|
|
|
256
|
+
def test_to_result_keeps_a_long_explanation() -> None:
|
|
257
|
+
writeup = "Cascade: fast flagged this; the slow pass is the verdict.\n\n" + ("A posed adult. " * 400)
|
|
258
|
+
writeup = writeup.strip()
|
|
259
|
+
assert 4000 < len(writeup) < singguard._EXPLANATION_MAX
|
|
260
|
+
result = singguard.to_result(
|
|
261
|
+
{"safe": True, "verdict": "safe", "rule": "Safe", "explanation": writeup, "categories": []}
|
|
262
|
+
)
|
|
263
|
+
assert result.explanation == writeup
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_to_result_clips_explanation_at_max() -> None:
|
|
267
|
+
exact = "y" * singguard._EXPLANATION_MAX
|
|
268
|
+
over = exact + "z"
|
|
269
|
+
payload = {"safe": True, "verdict": "safe", "rule": "Safe", "categories": []}
|
|
270
|
+
assert singguard.to_result({**payload, "explanation": exact}).explanation == exact
|
|
271
|
+
assert singguard.to_result({**payload, "explanation": over}).explanation == exact
|
|
272
|
+
|
|
273
|
+
|
|
256
274
|
def test_singguard_check(singguard_server) -> None:
|
|
257
275
|
fake, url = singguard_server
|
|
258
276
|
client = SingGuardClient(url, timeout=5, thinking="fast-slow", policy="### A. Minors")
|
|
@@ -8,7 +8,13 @@ from argus_cortex.moderation import ModerationConfigError, SingGuardClient, poli
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def test_every_policy_has_a_text_and_an_illegal_table() -> None:
|
|
11
|
-
assert policies.POLICIES == (
|
|
11
|
+
assert policies.POLICIES == (
|
|
12
|
+
"default",
|
|
13
|
+
"adult",
|
|
14
|
+
"adult-granular",
|
|
15
|
+
"adult-cautious",
|
|
16
|
+
"adult-cinematic",
|
|
17
|
+
)
|
|
12
18
|
assert policies.policy_text("default") is None # SingGuard's own rules: nothing is sent
|
|
13
19
|
for name in policies.POLICIES[1:]:
|
|
14
20
|
text = policies.policy_text(name)
|
|
@@ -22,6 +28,7 @@ def test_illegal_rules_are_per_policy() -> None:
|
|
|
22
28
|
assert policies.illegal_rules("default") == frozenset() # its A is all sexual content, adult included
|
|
23
29
|
assert policies.illegal_rules("adult-granular") == {"A", "H", "I"}
|
|
24
30
|
assert policies.illegal_rules("adult") == policies.illegal_rules("adult-cautious") == {"A"}
|
|
31
|
+
assert policies.illegal_rules("adult-cinematic") == {"A"}
|
|
25
32
|
# Each illegal letter is a heading in that policy's own text.
|
|
26
33
|
for name in policies.POLICIES[1:]:
|
|
27
34
|
for letter in policies.illegal_rules(name):
|
|
@@ -31,7 +38,8 @@ def test_illegal_rules_are_per_policy() -> None:
|
|
|
31
38
|
def test_is_illegal_reads_a_rule_as_verdicts_report_it() -> None:
|
|
32
39
|
assert policies.is_illegal("adult-granular", "H. Non-Consensual Sexual Content")
|
|
33
40
|
assert policies.is_illegal("adult-granular", "I") and policies.is_illegal("adult-granular", "a.")
|
|
34
|
-
assert policies.is_illegal("adult", "A. Illegal
|
|
41
|
+
assert policies.is_illegal("adult", "A. Illegal Sexual Content")
|
|
42
|
+
assert policies.is_illegal("adult", "A. Illegal or Extreme Sexual Content") # an older recorded title
|
|
35
43
|
assert not policies.is_illegal("default", "A. Sexual Content Risk")
|
|
36
44
|
for name in policies.POLICIES: # crime and animal abuse go to review, never deletion
|
|
37
45
|
assert not policies.is_illegal(name, "B. Real-World Crimes & Public Safety")
|
|
@@ -39,10 +47,34 @@ def test_is_illegal_reads_a_rule_as_verdicts_report_it() -> None:
|
|
|
39
47
|
assert not policies.is_illegal(name, "Safe")
|
|
40
48
|
|
|
41
49
|
|
|
50
|
+
def test_adult_safe_names_explicit_commercial_content() -> None:
|
|
51
|
+
for name in ("adult", "adult-granular", "adult-cautious", "adult-cinematic"):
|
|
52
|
+
text = policies.policy_text(name)
|
|
53
|
+
risks, safe = text.split("### Safe")
|
|
54
|
+
assert "Extreme Content" not in risks
|
|
55
|
+
assert "legal adult entertainment" in safe
|
|
56
|
+
assert "commercial porn" in safe
|
|
57
|
+
assert "adult-site watermark" in safe
|
|
58
|
+
assert "closest heading" in safe
|
|
59
|
+
assert "is not this rule" not in risks
|
|
60
|
+
assert "between adults" not in text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_adult_cinematic_narrows_crime_and_keeps_adult_entertainment_safe() -> None:
|
|
64
|
+
text = policies.policy_text("adult-cinematic")
|
|
65
|
+
risks, safe = text.split("### Safe")
|
|
66
|
+
assert "how to commit one" in risks
|
|
67
|
+
assert "violent crime, weapons, other crimes" not in risks
|
|
68
|
+
assert "bank robbery" in safe and "stealing pensions" in safe and "dirty cops" in safe
|
|
69
|
+
assert "Legal adult entertainment is Safe under this policy, the same as under adult" in safe
|
|
70
|
+
assert "between adults" not in text
|
|
71
|
+
assert policies.policy_text("adult-cinematic") != policies.policy_text("adult")
|
|
72
|
+
|
|
73
|
+
|
|
42
74
|
def test_mode_is_by_name() -> None:
|
|
43
75
|
assert policies.mode("default") == "sfw"
|
|
44
76
|
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"] *
|
|
77
|
+
assert [policies.mode(n) for n in policies.POLICIES[1:]] == ["nsfw"] * 4
|
|
46
78
|
assert policies.mode("defaults") == "nsfw"
|
|
47
79
|
|
|
48
80
|
|
|
@@ -206,9 +206,11 @@ def test_record_asset_returns_id_and_upserts_on_sha() -> None:
|
|
|
206
206
|
sql, params = conn.executed[-1]
|
|
207
207
|
assert "INSERT INTO source_asset" in sql
|
|
208
208
|
assert "ON CONFLICT (sha256)" in sql
|
|
209
|
-
# on a dedup hit
|
|
210
|
-
|
|
211
|
-
assert "
|
|
209
|
+
# on a dedup hit uri is refreshed, but a call without an immich_id or a
|
|
210
|
+
# metadata key keeps the stored one (another stage may have recorded it)
|
|
211
|
+
assert "uri = EXCLUDED.uri" in sql
|
|
212
|
+
assert "immich_id = COALESCE(EXCLUDED.immich_id, source_asset.immich_id)" in sql
|
|
213
|
+
assert "metadata = source_asset.metadata || EXCLUDED.metadata" in sql
|
|
212
214
|
# metadata is serialised to a JSON string for the ::jsonb cast
|
|
213
215
|
assert params == ("immich://1", "abc", None, '{"k": "v"}')
|
|
214
216
|
|
|
@@ -81,6 +81,20 @@ def test_transaction_rolls_back_on_error(store: PostgresLineageStore) -> None:
|
|
|
81
81
|
assert reused is not None
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def test_reupsert_merges_metadata_and_keeps_immich_id(store: PostgresLineageStore) -> None:
|
|
85
|
+
# quarry records provenance first; lens later records the same bytes with
|
|
86
|
+
# none: the provenance and immich id survive, the uri moves, shared keys update.
|
|
87
|
+
sha = uuid.uuid4().hex
|
|
88
|
+
first = store.record_asset(
|
|
89
|
+
SourceAsset(uri="quarry://a", sha256=sha, immich_id="im-1", metadata={"license": "cc0", "seen": 1})
|
|
90
|
+
)
|
|
91
|
+
again = store.record_asset(SourceAsset(uri="/data/images/a.png", sha256=sha, metadata={"seen": 2}))
|
|
92
|
+
assert again == first
|
|
93
|
+
with store._acquire() as conn:
|
|
94
|
+
row = conn.execute("SELECT uri, immich_id, metadata FROM source_asset WHERE id = %s", (first,)).fetchone()
|
|
95
|
+
assert row == ("/data/images/a.png", "im-1", {"license": "cc0", "seen": 2})
|
|
96
|
+
|
|
97
|
+
|
|
84
98
|
def test_concurrent_writes_are_isolated(store: PostgresLineageStore) -> None:
|
|
85
99
|
# Many concurrent record_asset calls each borrow their own pooled connection;
|
|
86
100
|
# all succeed with distinct ids (no "another operation in progress").
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|