civon-image-edit-audit 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.
- civon_image_edit_audit-0.1.0/.gitignore +27 -0
- civon_image_edit_audit-0.1.0/LICENSE +7 -0
- civon_image_edit_audit-0.1.0/PKG-INFO +81 -0
- civon_image_edit_audit-0.1.0/README.md +68 -0
- civon_image_edit_audit-0.1.0/pyproject.toml +27 -0
- civon_image_edit_audit-0.1.0/src/civon_image_edit_audit/__init__.py +5 -0
- civon_image_edit_audit-0.1.0/src/civon_image_edit_audit/audit.py +150 -0
- civon_image_edit_audit-0.1.0/src/civon_image_edit_audit/cli.py +30 -0
- civon_image_edit_audit-0.1.0/tests/test_audit.py +167 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Dependencies and local tool state
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
.DS_Store
|
|
5
|
+
|
|
6
|
+
# Local environment files
|
|
7
|
+
.env
|
|
8
|
+
.env.*
|
|
9
|
+
!.env.example
|
|
10
|
+
|
|
11
|
+
# Runtime output is local-only by default: JSON logs, helper scripts, screenshots,
|
|
12
|
+
# browser snapshots, network captures, and lock files all belong here.
|
|
13
|
+
runs/*
|
|
14
|
+
!runs/.gitkeep
|
|
15
|
+
|
|
16
|
+
# A human may deliberately promote a small, redacted JSON receipt as durable
|
|
17
|
+
# evidence. Promotion is explicit by path, not automatic from a runner.
|
|
18
|
+
!runs/evidence/
|
|
19
|
+
runs/evidence/*
|
|
20
|
+
!runs/evidence/README.md
|
|
21
|
+
!runs/evidence/*.json
|
|
22
|
+
|
|
23
|
+
# Target-size media exports are reproducible local cache files. Reviewed shared
|
|
24
|
+
# masters under assets/<site>/_shared/v*/ remain tracked. Add one export with
|
|
25
|
+
# `git add -f` only when an exact reviewed file must survive across machines for
|
|
26
|
+
# a registered publishing asset; never force-add the whole export cache.
|
|
27
|
+
assets/**/_shared/exports/
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
LicenseRef-NoLicenseGrant
|
|
2
|
+
|
|
3
|
+
This prepared package makes no open-source or other package-use license grant.
|
|
4
|
+
No ownership certificate, exclusivity claim, third-party license or permission
|
|
5
|
+
to use product inputs or outputs is asserted. Distribution rights and registry
|
|
6
|
+
publication must be confirmed by the authorised owner before execution.
|
|
7
|
+
Local preparation does not upload or publish this package.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: civon-image-edit-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit human image-edit records and quote totals with a dependency-free Python library and CLI.
|
|
5
|
+
Project-URL: Homepage, https://civon.ai/image-editor
|
|
6
|
+
License-Expression: LicenseRef-NoLicenseGrant
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: image-editing,manual-review,record-validation,workflow
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# civon-image-edit-audit
|
|
15
|
+
|
|
16
|
+
A dependency-free Python audit library and local CLI for **human-entered image-edit records**. It validates a JSON interchange format, identifies incomplete checks and failed candidates, counts manual acceptances, and sums recorded credit quotes with decimal arithmetic. It also produces a compact Markdown report.
|
|
17
|
+
|
|
18
|
+
This is an independent workflow utility, not an official Civon generation SDK. It does not generate, inspect, edit, hash or upload images, read an account balance, fetch current prices, run a model or verify rights. The decisions reflect only the supplied human assertions. Original AI-assisted code and text; no private product implementation, image, credential or third-party runtime package is included.
|
|
19
|
+
|
|
20
|
+
## Local installation and runtime
|
|
21
|
+
|
|
22
|
+
The prepared release contains a real wheel and source distribution. Install the actual local wheel in a virtual environment:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pip install --no-index --no-deps /absolute/path/to/civon_image_edit_audit-0.1.0-py3-none-any.whl
|
|
26
|
+
civon-edit-audit records.json
|
|
27
|
+
civon-edit-audit --markdown records.json
|
|
28
|
+
civon-edit-audit - < records.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python 3.10 or later is declared. Local preparation tests the installed artifacts on the available Python runtime and records that version; it does not claim a full version matrix. Hatchling and build are build tools, not runtime dependencies. No PyPI or TestPyPI upload, live project URL or name reservation is established by this bundle.
|
|
32
|
+
|
|
33
|
+
## Python API
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from civon_image_edit_audit import audit_record, audit_records, load_records, to_markdown
|
|
37
|
+
|
|
38
|
+
candidate = {
|
|
39
|
+
"schema": "civon-image-edit-record/v1",
|
|
40
|
+
"id": "draft-1",
|
|
41
|
+
"candidate": "mug-background-v1.png",
|
|
42
|
+
"prompt": "Explore a warmer backdrop; inspect lettering.",
|
|
43
|
+
"settings": "Manually recorded model, ratio and resolution",
|
|
44
|
+
"quotedCredits": "2.5", # synthetic example, not a product price
|
|
45
|
+
"checks": {
|
|
46
|
+
"subject": "pass", "text": "fail", "geometry": "pass",
|
|
47
|
+
"edges": "pass", "lighting": "pass", "delivery": "pass"
|
|
48
|
+
},
|
|
49
|
+
"permissionsConfirmed": False
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
result = audit_record(candidate)
|
|
53
|
+
assert result.status == "revise"
|
|
54
|
+
assert result.failed == ("text",)
|
|
55
|
+
report = audit_records([candidate])
|
|
56
|
+
assert report["quotedCredits"] == "2.5"
|
|
57
|
+
print(to_markdown([candidate]))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
These sample checks are fictional and do not describe an actual image analysis or product result. `AuditResult` is a frozen dataclass; audit functions do not mutate the supplied records.
|
|
61
|
+
|
|
62
|
+
## Interchange contract
|
|
63
|
+
|
|
64
|
+
The input is an array of `civon-image-edit-record/v1` records, compatible with JSON-serialised records from the separately prepared JavaScript record utility. This Python package does not require or execute that JavaScript package.
|
|
65
|
+
|
|
66
|
+
- Required non-empty strings: `id` (up to 160 characters), `candidate` (250), `prompt` (2000), `settings` (2000).
|
|
67
|
+
- `quotedCredits` is a decimal string with at most six whole digits and three fractional digits, or null/omitted for unknown. An explicit `"0"` is distinct from unknown. Numbers, negative values, exponent notation and excessive precision are rejected. Decimal arithmetic totals all known quotes, including rejected candidates; these are entries, not charges, prices or an account balance.
|
|
68
|
+
- Optional `sourceSha256` and `candidateSha256` contain 64 hexadecimal characters or null. Only their syntax is checked; files are not opened or hashed. Optional `notes` is non-empty text up to 2000 characters, or null/omitted.
|
|
69
|
+
- `checks` contains exactly `subject`, `text`, `geometry`, `edges`, `lighting` and `delivery`. Each value is `pending`, `pass`, `fail` or `na`. `permissionsConfirmed` must be an actual boolean, not a string or number.
|
|
70
|
+
- A failed check yields `revise`; a pending check yields `pending`. All N/A is insufficient. Acceptance requires at least one Pass and confirmed permissions after all checks resolve. The permissions field records a user assertion, not legal clearance.
|
|
71
|
+
- Arrays are limited to 10,000 records, with unique trimmed ids. Errors identify the zero-based record index where applicable. `load_records` rejects duplicate JSON keys, non-finite constants and malformed/deeply nested JSON. Unknown top-level extra fields are ignored; unknown check fields are rejected.
|
|
72
|
+
|
|
73
|
+
The CLI reads at most 5 MiB plus one byte to detect oversized input. It accepts one local UTF-8 file or stdin, prints a report and writes no file. Invalid data returns status 1, argument errors 2, and valid reports/help 0. `to_markdown` escapes table delimiters and line breaks; it is not an HTML sanitiser. Keep exported prompts, filenames, settings and notes private when needed.
|
|
74
|
+
|
|
75
|
+
## Provenance and license posture
|
|
76
|
+
|
|
77
|
+
Metadata uses `LicenseRef-NoLicenseGrant` with the included notice. This preparation grants no open-source or other package-use license and makes no ownership or exclusivity assurance. Account authority, distribution rights, namespace/version availability and final publication remain execution checks. No author, email, source repository or issue tracker is invented.
|
|
78
|
+
|
|
79
|
+
## Related product context
|
|
80
|
+
|
|
81
|
+
[Civon AI Image Editor](https://civon.ai/image-editor) is a browser-based, prompt-led still-image editor. Model choices, usable reference counts, settings, access and quoted credits vary. This audit utility works without visiting or connecting to it and provides no model integration, endorsement by PyPI or guarantee about an edited image.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# civon-image-edit-audit
|
|
2
|
+
|
|
3
|
+
A dependency-free Python audit library and local CLI for **human-entered image-edit records**. It validates a JSON interchange format, identifies incomplete checks and failed candidates, counts manual acceptances, and sums recorded credit quotes with decimal arithmetic. It also produces a compact Markdown report.
|
|
4
|
+
|
|
5
|
+
This is an independent workflow utility, not an official Civon generation SDK. It does not generate, inspect, edit, hash or upload images, read an account balance, fetch current prices, run a model or verify rights. The decisions reflect only the supplied human assertions. Original AI-assisted code and text; no private product implementation, image, credential or third-party runtime package is included.
|
|
6
|
+
|
|
7
|
+
## Local installation and runtime
|
|
8
|
+
|
|
9
|
+
The prepared release contains a real wheel and source distribution. Install the actual local wheel in a virtual environment:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
python -m pip install --no-index --no-deps /absolute/path/to/civon_image_edit_audit-0.1.0-py3-none-any.whl
|
|
13
|
+
civon-edit-audit records.json
|
|
14
|
+
civon-edit-audit --markdown records.json
|
|
15
|
+
civon-edit-audit - < records.json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Python 3.10 or later is declared. Local preparation tests the installed artifacts on the available Python runtime and records that version; it does not claim a full version matrix. Hatchling and build are build tools, not runtime dependencies. No PyPI or TestPyPI upload, live project URL or name reservation is established by this bundle.
|
|
19
|
+
|
|
20
|
+
## Python API
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from civon_image_edit_audit import audit_record, audit_records, load_records, to_markdown
|
|
24
|
+
|
|
25
|
+
candidate = {
|
|
26
|
+
"schema": "civon-image-edit-record/v1",
|
|
27
|
+
"id": "draft-1",
|
|
28
|
+
"candidate": "mug-background-v1.png",
|
|
29
|
+
"prompt": "Explore a warmer backdrop; inspect lettering.",
|
|
30
|
+
"settings": "Manually recorded model, ratio and resolution",
|
|
31
|
+
"quotedCredits": "2.5", # synthetic example, not a product price
|
|
32
|
+
"checks": {
|
|
33
|
+
"subject": "pass", "text": "fail", "geometry": "pass",
|
|
34
|
+
"edges": "pass", "lighting": "pass", "delivery": "pass"
|
|
35
|
+
},
|
|
36
|
+
"permissionsConfirmed": False
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
result = audit_record(candidate)
|
|
40
|
+
assert result.status == "revise"
|
|
41
|
+
assert result.failed == ("text",)
|
|
42
|
+
report = audit_records([candidate])
|
|
43
|
+
assert report["quotedCredits"] == "2.5"
|
|
44
|
+
print(to_markdown([candidate]))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
These sample checks are fictional and do not describe an actual image analysis or product result. `AuditResult` is a frozen dataclass; audit functions do not mutate the supplied records.
|
|
48
|
+
|
|
49
|
+
## Interchange contract
|
|
50
|
+
|
|
51
|
+
The input is an array of `civon-image-edit-record/v1` records, compatible with JSON-serialised records from the separately prepared JavaScript record utility. This Python package does not require or execute that JavaScript package.
|
|
52
|
+
|
|
53
|
+
- Required non-empty strings: `id` (up to 160 characters), `candidate` (250), `prompt` (2000), `settings` (2000).
|
|
54
|
+
- `quotedCredits` is a decimal string with at most six whole digits and three fractional digits, or null/omitted for unknown. An explicit `"0"` is distinct from unknown. Numbers, negative values, exponent notation and excessive precision are rejected. Decimal arithmetic totals all known quotes, including rejected candidates; these are entries, not charges, prices or an account balance.
|
|
55
|
+
- Optional `sourceSha256` and `candidateSha256` contain 64 hexadecimal characters or null. Only their syntax is checked; files are not opened or hashed. Optional `notes` is non-empty text up to 2000 characters, or null/omitted.
|
|
56
|
+
- `checks` contains exactly `subject`, `text`, `geometry`, `edges`, `lighting` and `delivery`. Each value is `pending`, `pass`, `fail` or `na`. `permissionsConfirmed` must be an actual boolean, not a string or number.
|
|
57
|
+
- A failed check yields `revise`; a pending check yields `pending`. All N/A is insufficient. Acceptance requires at least one Pass and confirmed permissions after all checks resolve. The permissions field records a user assertion, not legal clearance.
|
|
58
|
+
- Arrays are limited to 10,000 records, with unique trimmed ids. Errors identify the zero-based record index where applicable. `load_records` rejects duplicate JSON keys, non-finite constants and malformed/deeply nested JSON. Unknown top-level extra fields are ignored; unknown check fields are rejected.
|
|
59
|
+
|
|
60
|
+
The CLI reads at most 5 MiB plus one byte to detect oversized input. It accepts one local UTF-8 file or stdin, prints a report and writes no file. Invalid data returns status 1, argument errors 2, and valid reports/help 0. `to_markdown` escapes table delimiters and line breaks; it is not an HTML sanitiser. Keep exported prompts, filenames, settings and notes private when needed.
|
|
61
|
+
|
|
62
|
+
## Provenance and license posture
|
|
63
|
+
|
|
64
|
+
Metadata uses `LicenseRef-NoLicenseGrant` with the included notice. This preparation grants no open-source or other package-use license and makes no ownership or exclusivity assurance. Account authority, distribution rights, namespace/version availability and final publication remain execution checks. No author, email, source repository or issue tracker is invented.
|
|
65
|
+
|
|
66
|
+
## Related product context
|
|
67
|
+
|
|
68
|
+
[Civon AI Image Editor](https://civon.ai/image-editor) is a browser-based, prompt-led still-image editor. Model choices, usable reference counts, settings, access and quoted credits vary. This audit utility works without visiting or connecting to it and provides no model integration, endorsement by PyPI or guarantee about an edited image.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "civon-image-edit-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Audit human image-edit records and quote totals with a dependency-free Python library and CLI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "LicenseRef-NoLicenseGrant"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["image-editing", "manual-review", "record-validation", "workflow"]
|
|
15
|
+
classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://civon.ai/image-editor"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
civon-edit-audit = "civon_image_edit_audit.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/civon_image_edit_audit"]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.sdist]
|
|
27
|
+
include = ["src/", "tests/", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Audit manual decisions; no image analysis, network I/O or rights verification."""
|
|
2
|
+
from .audit import AuditError, AuditResult, audit_record, audit_records, load_records, to_markdown
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
__all__ = ["AuditError", "AuditResult", "audit_record", "audit_records", "load_records", "to_markdown"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Validate the civon-image-edit-record/v1 JSON interchange format."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from decimal import Decimal, localcontext
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
CHECKS = ("subject", "text", "geometry", "edges", "lighting", "delivery")
|
|
9
|
+
RESULTS = frozenset(("pending", "pass", "fail", "na"))
|
|
10
|
+
SCHEMA = "civon-image-edit-record/v1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuditError(ValueError):
|
|
14
|
+
"""An input violates the documented manual-record contract."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AuditResult:
|
|
19
|
+
record_id: str
|
|
20
|
+
candidate: str
|
|
21
|
+
status: str
|
|
22
|
+
reason: str
|
|
23
|
+
quoted_credits: Decimal | None
|
|
24
|
+
failed: tuple[str, ...]
|
|
25
|
+
pending: tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _text(value: Any, field: str, maximum: int) -> str:
|
|
29
|
+
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
|
|
30
|
+
raise AuditError(f"{field} must be non-empty text (maximum {maximum} characters)")
|
|
31
|
+
return value.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _quote(value: Any) -> Decimal | None:
|
|
35
|
+
if value is None:
|
|
36
|
+
return None
|
|
37
|
+
if not isinstance(value, str) or not re.fullmatch(r"[0-9]{1,6}(?:\.[0-9]{1,3})?", value):
|
|
38
|
+
raise AuditError("quotedCredits must be a decimal string with up to 6 whole digits and 3 decimal places, or null")
|
|
39
|
+
return Decimal(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _amount(value: Decimal) -> str:
|
|
43
|
+
text = format(value, "f")
|
|
44
|
+
return text.rstrip("0").rstrip(".") if "." in text else text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def audit_record(record: Any) -> AuditResult:
|
|
48
|
+
"""Validate a record and derive status from the supplied human assertions."""
|
|
49
|
+
if not isinstance(record, dict) or record.get("schema") != SCHEMA:
|
|
50
|
+
raise AuditError("Unsupported image-edit record schema")
|
|
51
|
+
record_id = _text(record.get("id"), "id", 160)
|
|
52
|
+
candidate = _text(record.get("candidate"), "candidate", 250)
|
|
53
|
+
_text(record.get("prompt"), "prompt", 2000)
|
|
54
|
+
_text(record.get("settings"), "settings", 2000)
|
|
55
|
+
quote = _quote(record.get("quotedCredits"))
|
|
56
|
+
for name in ("sourceSha256", "candidateSha256"):
|
|
57
|
+
value = record.get(name)
|
|
58
|
+
if value is not None and (not isinstance(value, str) or not re.fullmatch(r"[a-fA-F0-9]{64}", value)):
|
|
59
|
+
raise AuditError(f"{name} must be SHA-256 hexadecimal text or null")
|
|
60
|
+
if record.get("notes") is not None:
|
|
61
|
+
_text(record["notes"], "notes", 2000)
|
|
62
|
+
checks = record.get("checks")
|
|
63
|
+
if not isinstance(checks, dict) or set(checks) != set(CHECKS):
|
|
64
|
+
raise AuditError("checks must contain exactly the six documented names")
|
|
65
|
+
if any(not isinstance(value, str) or value not in RESULTS for value in checks.values()):
|
|
66
|
+
raise AuditError("Invalid check result")
|
|
67
|
+
if type(record.get("permissionsConfirmed")) is not bool:
|
|
68
|
+
raise AuditError("permissionsConfirmed must be a boolean")
|
|
69
|
+
failed = tuple(name for name in CHECKS if checks[name] == "fail")
|
|
70
|
+
pending = tuple(name for name in CHECKS if checks[name] == "pending")
|
|
71
|
+
if failed:
|
|
72
|
+
status, reason = "revise", "failed_checks"
|
|
73
|
+
elif pending:
|
|
74
|
+
status, reason = "pending", "incomplete_checks"
|
|
75
|
+
elif not any(value == "pass" for value in checks.values()):
|
|
76
|
+
status, reason = "pending", "no_positive_check"
|
|
77
|
+
elif not record["permissionsConfirmed"]:
|
|
78
|
+
status, reason = "pending", "permissions_unconfirmed"
|
|
79
|
+
else:
|
|
80
|
+
status, reason = "accepted", "manual_checks_complete"
|
|
81
|
+
return AuditResult(record_id, candidate, status, reason, quote, failed, pending)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def audit_records(records: Any) -> dict[str, Any]:
|
|
85
|
+
"""Audit up to 10,000 records, reject duplicate ids, and total all known quotes."""
|
|
86
|
+
if not isinstance(records, list) or len(records) > 10000:
|
|
87
|
+
raise AuditError("Expected a JSON array containing at most 10000 records")
|
|
88
|
+
ids: set[str] = set()
|
|
89
|
+
outcomes = []
|
|
90
|
+
decisions = {"pending": 0, "revise": 0, "accepted": 0}
|
|
91
|
+
values, unknown = [], 0
|
|
92
|
+
for index, record in enumerate(records):
|
|
93
|
+
try:
|
|
94
|
+
result = audit_record(record)
|
|
95
|
+
if result.record_id in ids:
|
|
96
|
+
raise AuditError(f"Duplicate record id: {result.record_id}")
|
|
97
|
+
except AuditError as error:
|
|
98
|
+
raise AuditError(f"Record {index}: {error}") from error
|
|
99
|
+
ids.add(result.record_id)
|
|
100
|
+
decisions[result.status] += 1
|
|
101
|
+
if result.quoted_credits is None:
|
|
102
|
+
unknown += 1
|
|
103
|
+
else:
|
|
104
|
+
values.append(result.quoted_credits)
|
|
105
|
+
outcomes.append({"id": result.record_id, "candidate": result.candidate, "status": result.status,
|
|
106
|
+
"reason": result.reason, "quotedCredits": None if result.quoted_credits is None else _amount(result.quoted_credits),
|
|
107
|
+
"failed": list(result.failed), "pending": list(result.pending)})
|
|
108
|
+
with localcontext() as context:
|
|
109
|
+
context.prec = 28
|
|
110
|
+
total = sum(values, Decimal("0"))
|
|
111
|
+
return {"schema": "civon-image-edit-audit/v1", "records": len(records), "quotedCredits": _amount(total),
|
|
112
|
+
"unknownQuotes": unknown, "decisions": decisions, "quoteMeaning": "sum_of_manually_entered_quotes_not_charges",
|
|
113
|
+
"results": outcomes}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
117
|
+
result = {}
|
|
118
|
+
for key, value in pairs:
|
|
119
|
+
if key in result:
|
|
120
|
+
raise AuditError(f"Duplicate JSON key: {key}")
|
|
121
|
+
result[key] = value
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _constant(value: str) -> None:
|
|
126
|
+
raise AuditError(f"Non-finite JSON constant: {value}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_records(text: str) -> list[dict[str, Any]]:
|
|
130
|
+
"""Decode strict JSON and validate before returning the records."""
|
|
131
|
+
try:
|
|
132
|
+
records = json.loads(text, object_pairs_hook=_pairs, parse_constant=_constant)
|
|
133
|
+
except (json.JSONDecodeError, RecursionError) as error:
|
|
134
|
+
raise AuditError(f"Invalid JSON: {error}") from error
|
|
135
|
+
audit_records(records)
|
|
136
|
+
return records
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def to_markdown(records: Any) -> str:
|
|
140
|
+
"""Render a compact table, escaping table pipes and line breaks, not HTML."""
|
|
141
|
+
report = audit_records(records)
|
|
142
|
+
def cell(value: Any) -> str:
|
|
143
|
+
return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
|
144
|
+
lines = ["# Manual image-edit audit", "", "Human assertions and quoted credits; not image analysis, billing or rights verification.", "",
|
|
145
|
+
"| Record | Candidate | Quote | Decision |", "| --- | --- | --- | --- |"]
|
|
146
|
+
for row in report["results"]:
|
|
147
|
+
quote = "Unknown" if row["quotedCredits"] is None else row["quotedCredits"]
|
|
148
|
+
lines.append("| " + " | ".join(cell(v) for v in (row["id"], row["candidate"], quote, row["status"])) + " |")
|
|
149
|
+
lines += ["", f"Total entered quotes: {report['quotedCredits']}; unknown quotes: {report['unknownQuotes']}.", ""]
|
|
150
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Read one local JSON array or stdin; write an audit to stdout."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from .audit import AuditError, audit_records, load_records, to_markdown
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv: list[str] | None = None) -> int:
|
|
9
|
+
parser = argparse.ArgumentParser(description="Audit human image-edit records; no image analysis or network access.")
|
|
10
|
+
parser.add_argument("file", help="local JSON file, or - for stdin")
|
|
11
|
+
parser.add_argument("--markdown", action="store_true", help="write a compact Markdown table")
|
|
12
|
+
args = parser.parse_args(argv)
|
|
13
|
+
try:
|
|
14
|
+
if args.file == "-":
|
|
15
|
+
data = sys.stdin.buffer.read(5 * 1024 * 1024 + 1)
|
|
16
|
+
else:
|
|
17
|
+
with open(args.file, "rb") as stream:
|
|
18
|
+
data = stream.read(5 * 1024 * 1024 + 1)
|
|
19
|
+
if len(data) > 5 * 1024 * 1024:
|
|
20
|
+
raise AuditError("Input exceeds 5 MiB")
|
|
21
|
+
records = load_records(data.decode("utf-8"))
|
|
22
|
+
print(to_markdown(records) if args.markdown else json.dumps(audit_records(records), indent=2, ensure_ascii=False))
|
|
23
|
+
return 0
|
|
24
|
+
except (AuditError, OSError, UnicodeError, RecursionError) as error:
|
|
25
|
+
print(f"Invalid edit records: {error}", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from dataclasses import FrozenInstanceError
|
|
3
|
+
from decimal import Decimal, localcontext
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
from civon_image_edit_audit import AuditError, audit_record, audit_records, load_records, to_markdown
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def record(identifier="a", quote=None):
|
|
13
|
+
return {"schema": "civon-image-edit-record/v1", "id": identifier, "candidate": "mug.png",
|
|
14
|
+
"prompt": "A warmer backdrop", "settings": "Manual settings", "quotedCredits": quote,
|
|
15
|
+
"checks": dict.fromkeys(("subject", "text", "geometry", "edges", "lighting", "delivery"), "pending"),
|
|
16
|
+
"permissionsConfirmed": False}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditTests(unittest.TestCase):
|
|
20
|
+
def test_pending_and_immutable(self):
|
|
21
|
+
r = record()
|
|
22
|
+
before = copy.deepcopy(r)
|
|
23
|
+
result = audit_record(r)
|
|
24
|
+
self.assertEqual(result.status, "pending")
|
|
25
|
+
self.assertEqual(len(result.pending), 6)
|
|
26
|
+
self.assertEqual(r, before)
|
|
27
|
+
with self.assertRaises(FrozenInstanceError):
|
|
28
|
+
result.status = "accepted"
|
|
29
|
+
|
|
30
|
+
def test_permissions_gate_and_acceptance(self):
|
|
31
|
+
r = record()
|
|
32
|
+
r["checks"] = dict.fromkeys(r["checks"], "pass")
|
|
33
|
+
self.assertEqual(audit_record(r).reason, "permissions_unconfirmed")
|
|
34
|
+
r["permissionsConfirmed"] = True
|
|
35
|
+
self.assertEqual(audit_record(r).status, "accepted")
|
|
36
|
+
|
|
37
|
+
def test_failure_precedes_pending(self):
|
|
38
|
+
r = record()
|
|
39
|
+
r["checks"]["text"] = "fail"
|
|
40
|
+
self.assertEqual(audit_record(r).status, "revise")
|
|
41
|
+
self.assertEqual(audit_record(r).failed, ("text",))
|
|
42
|
+
|
|
43
|
+
def test_na_is_not_positive_evidence(self):
|
|
44
|
+
r = record()
|
|
45
|
+
r["checks"] = dict.fromkeys(r["checks"], "na")
|
|
46
|
+
r["permissionsConfirmed"] = True
|
|
47
|
+
self.assertEqual(audit_record(r).reason, "no_positive_check")
|
|
48
|
+
r["checks"]["subject"] = "pass"
|
|
49
|
+
self.assertEqual(audit_record(r).status, "accepted")
|
|
50
|
+
|
|
51
|
+
def test_quote_precision_and_unknown(self):
|
|
52
|
+
data = [record("a", "0.1"), record("b", "0.2"), record("c", None), record("d", "0")]
|
|
53
|
+
report = audit_records(data)
|
|
54
|
+
self.assertEqual(report["quotedCredits"], "0.3")
|
|
55
|
+
self.assertEqual(report["unknownQuotes"], 1)
|
|
56
|
+
self.assertEqual(audit_record(record(quote="999999.999")).quoted_credits, Decimal("999999.999"))
|
|
57
|
+
with localcontext() as context:
|
|
58
|
+
context.prec = 2
|
|
59
|
+
self.assertEqual(audit_records([record("a", "999999.999"), record("b", "0.001")])["quotedCredits"], "1000000")
|
|
60
|
+
|
|
61
|
+
def test_quotes_reject_non_contract_values(self):
|
|
62
|
+
for quote in [-1, 0, 1.2, True, "-1", "NaN", "Infinity", "1e3", "1.0001", "1000000", "", "١"]:
|
|
63
|
+
with self.subTest(quote=quote), self.assertRaises(AuditError):
|
|
64
|
+
audit_record(record(quote=quote))
|
|
65
|
+
|
|
66
|
+
def test_record_shape(self):
|
|
67
|
+
for value in [None, [], {}, {"schema": "other"}]:
|
|
68
|
+
with self.subTest(value=value), self.assertRaises(AuditError):
|
|
69
|
+
audit_record(value)
|
|
70
|
+
|
|
71
|
+
def test_required_strings(self):
|
|
72
|
+
for field in ["id", "candidate", "prompt", "settings"]:
|
|
73
|
+
for value in ["", " ", None, 2, True]:
|
|
74
|
+
with self.subTest(field=field, value=value), self.assertRaises(AuditError):
|
|
75
|
+
r = record()
|
|
76
|
+
r[field] = value
|
|
77
|
+
audit_record(r)
|
|
78
|
+
for field, limit in [("id", 160), ("candidate", 250), ("prompt", 2000), ("settings", 2000)]:
|
|
79
|
+
r = record()
|
|
80
|
+
r[field] = "x" * (limit + 1)
|
|
81
|
+
with self.assertRaises(AuditError):
|
|
82
|
+
audit_record(r)
|
|
83
|
+
|
|
84
|
+
def test_checks_exact_shape(self):
|
|
85
|
+
for change in ["missing", "extra", "invalid", "list", "unhashable"]:
|
|
86
|
+
r = record()
|
|
87
|
+
if change == "missing": del r["checks"]["text"]
|
|
88
|
+
if change == "extra": r["checks"]["invented"] = "pass"
|
|
89
|
+
if change == "invalid": r["checks"]["text"] = "yes"
|
|
90
|
+
if change == "list": r["checks"] = []
|
|
91
|
+
if change == "unhashable": r["checks"]["text"] = []
|
|
92
|
+
with self.subTest(change=change), self.assertRaises(AuditError):
|
|
93
|
+
audit_record(r)
|
|
94
|
+
|
|
95
|
+
def test_permission_boolean(self):
|
|
96
|
+
for value in [1, 0, "true", None, []]:
|
|
97
|
+
r = record()
|
|
98
|
+
r["permissionsConfirmed"] = value
|
|
99
|
+
with self.subTest(value=value), self.assertRaises(AuditError):
|
|
100
|
+
audit_record(r)
|
|
101
|
+
|
|
102
|
+
def test_checksums_and_notes(self):
|
|
103
|
+
r = record()
|
|
104
|
+
r["sourceSha256"] = "AB" * 32
|
|
105
|
+
r["candidateSha256"] = None
|
|
106
|
+
r["notes"] = "Private observation"
|
|
107
|
+
audit_record(r)
|
|
108
|
+
for field, value in [("sourceSha256", "bad"), ("candidateSha256", 3), ("notes", ""), ("notes", "x" * 2001)]:
|
|
109
|
+
bad = {**r, field: value}
|
|
110
|
+
with self.subTest(field=field), self.assertRaises(AuditError):
|
|
111
|
+
audit_record(bad)
|
|
112
|
+
|
|
113
|
+
def test_duplicates_and_error_index(self):
|
|
114
|
+
with self.assertRaisesRegex(AuditError, "Record 1: Duplicate"):
|
|
115
|
+
audit_records([record("a"), record(" a ")])
|
|
116
|
+
with self.assertRaisesRegex(AuditError, "Record 1:"):
|
|
117
|
+
audit_records([record(), {}])
|
|
118
|
+
|
|
119
|
+
def test_array_and_size(self):
|
|
120
|
+
self.assertEqual(audit_records([])["quotedCredits"], "0")
|
|
121
|
+
for data in [{}, None, [record()] * 10001]:
|
|
122
|
+
with self.subTest(kind=type(data).__name__), self.assertRaises(AuditError):
|
|
123
|
+
audit_records(data)
|
|
124
|
+
|
|
125
|
+
def test_json_strictness(self):
|
|
126
|
+
self.assertEqual(load_records(json.dumps([record()])), [record()])
|
|
127
|
+
for text in ['{"a":1,"a":2}', '[NaN]', '[Infinity]', '[', '[[]]', '[' * 2000 + ']' * 2000]:
|
|
128
|
+
with self.subTest(text=text[:20]), self.assertRaises(AuditError):
|
|
129
|
+
load_records(text)
|
|
130
|
+
|
|
131
|
+
def test_markdown_escape(self):
|
|
132
|
+
r = record("a|b")
|
|
133
|
+
r["candidate"] = "one\ntwo"
|
|
134
|
+
result = to_markdown([r])
|
|
135
|
+
self.assertIn("a\\|b", result)
|
|
136
|
+
self.assertIn("one two", result)
|
|
137
|
+
self.assertIn("Unknown", result)
|
|
138
|
+
|
|
139
|
+
def test_no_file_or_network_io(self):
|
|
140
|
+
with patch("builtins.open", side_effect=AssertionError("file I/O")), patch("socket.socket", side_effect=AssertionError("network")):
|
|
141
|
+
self.assertEqual(audit_records([record()])["records"], 1)
|
|
142
|
+
|
|
143
|
+
def test_summary_counts_failed_quotes(self):
|
|
144
|
+
r = record("failed", "2.50")
|
|
145
|
+
r["checks"]["text"] = "fail"
|
|
146
|
+
report = audit_records([r, record("pending", "1.25")])
|
|
147
|
+
self.assertEqual(report["quotedCredits"], "3.75")
|
|
148
|
+
self.assertEqual(report["decisions"], {"pending": 1, "revise": 1, "accepted": 0})
|
|
149
|
+
|
|
150
|
+
def test_cli(self):
|
|
151
|
+
def cli(args, data=b""):
|
|
152
|
+
return subprocess.run([sys.executable, "-m", "civon_image_edit_audit.cli", *args], input=data, capture_output=True)
|
|
153
|
+
data = json.dumps([record(quote="0.2")]).encode()
|
|
154
|
+
result = cli(["-"], data)
|
|
155
|
+
self.assertEqual(result.returncode, 0)
|
|
156
|
+
self.assertEqual(json.loads(result.stdout)["quotedCredits"], "0.2")
|
|
157
|
+
self.assertEqual(cli(["--markdown", "-"], data).returncode, 0)
|
|
158
|
+
self.assertEqual(cli(["--help"]).returncode, 0)
|
|
159
|
+
self.assertEqual(cli([]).returncode, 2)
|
|
160
|
+
self.assertEqual(cli(["-"], b"{bad").returncode, 1)
|
|
161
|
+
self.assertEqual(cli(["-"], b"\xff").returncode, 1)
|
|
162
|
+
self.assertEqual(cli(["-"], b" " * (5 * 1024 * 1024 + 1)).returncode, 1)
|
|
163
|
+
self.assertEqual(cli(["/nonexistent/civon-audit-test.json"]).returncode, 1)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
unittest.main()
|