awclassify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- awclassify/__init__.py +25 -0
- awclassify/__main__.py +8 -0
- awclassify/_doctor.py +161 -0
- awclassify/cli.py +161 -0
- awclassify/client.py +86 -0
- awclassify/rules.py +99 -0
- awclassify/taxonomy.py +22 -0
- awclassify-0.1.0.dist-info/METADATA +100 -0
- awclassify-0.1.0.dist-info/RECORD +13 -0
- awclassify-0.1.0.dist-info/WHEEL +5 -0
- awclassify-0.1.0.dist-info/entry_points.txt +2 -0
- awclassify-0.1.0.dist-info/licenses/LICENSE +15 -0
- awclassify-0.1.0.dist-info/top_level.txt +1 -0
awclassify/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""awclassify — Aither World Classify.
|
|
2
|
+
|
|
3
|
+
Classify any document the way the platform does: WHAT it is (doc_type),
|
|
4
|
+
WHO may read it (visibility), WHO it is for (audience), and WHAT it is
|
|
5
|
+
about (topics). A standalone, zero-dependency client for a classify-shaped
|
|
6
|
+
server, with a deterministic local classifier that works with no network.
|
|
7
|
+
|
|
8
|
+
Two modes:
|
|
9
|
+
- local rules — offline, deterministic, from filename/title/source.
|
|
10
|
+
NEVER asserts "public" without an explicit marker.
|
|
11
|
+
- server — POST the document to a classify-shaped surface
|
|
12
|
+
(the AitherOS platform's /classify, or any server
|
|
13
|
+
speaking the same contract) for the full LLM-powered
|
|
14
|
+
classification with confidence scores.
|
|
15
|
+
|
|
16
|
+
Fail-closed contract: a classification you cannot verify is NOT public.
|
|
17
|
+
`classified: False` with `visibility: internal` is the honest answer to
|
|
18
|
+
"can I ship this" until the server says otherwise.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from awclassify.rules import classify_rules
|
|
22
|
+
from awclassify.taxonomy import AUDIENCES, DOC_TYPES, VISIBILITIES
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__all__ = ["classify_rules", "DOC_TYPES", "VISIBILITIES", "AUDIENCES", "__version__"]
|
awclassify/__main__.py
ADDED
awclassify/_doctor.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Stack-aware `doctor` for awclassify.
|
|
2
|
+
|
|
3
|
+
GENERATED BY gen_aw_doctor.py -- DO NOT EDIT.
|
|
4
|
+
Regenerate it with the generator named above; a hand-edit here is reverted by
|
|
5
|
+
the next run and fails the parity gate.
|
|
6
|
+
|
|
7
|
+
Why a doctor exists at all: the aw* bricks are designed to COMPOSE, so the
|
|
8
|
+
interesting failures live BETWEEN them. "awclassify is installed" is not the useful
|
|
9
|
+
fact -- "awclassify is installed and the thing it pairs with is not" is. This reports
|
|
10
|
+
the whole stack, not just itself.
|
|
11
|
+
|
|
12
|
+
stdlib only, on purpose: a diagnostic that cannot run because a dependency is
|
|
13
|
+
missing is worthless precisely when you need it.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
#: Frozen from the Aither World registry at generation time. A shipped
|
|
23
|
+
#: package cannot read the registry, and a doctor that guessed at the family
|
|
24
|
+
#: would go stale in silence. Regenerate to update.
|
|
25
|
+
SELF = 'awclassify'
|
|
26
|
+
FAMILY = ['awask', 'awavatar', 'awbac', 'awbrain', 'awbrowse', 'awdecide', 'awdelphi', 'awdit', 'awembed', 'awevolve', 'awfind', 'awflow', 'awfocus', 'awgit', 'awgraph', 'awgym', 'awiam', 'awkno', 'awm', 'awmail', 'awnboard', 'awnest', 'awnet', 'awpool', 'awpredict', 'awprism', 'awprove', 'awreason', 'awrecover', 'awrecurse', 'awrelay', 'awrena', 'awrepl', 'awreport', 'awresearch', 'awrise', 'awrouter', 'awrtifact', 'awrun', 'awscreen', 'awseal', 'awsettings', 'awshare', 'awsprite', 'awstorage', 'awswarm', 'awtax', 'awtoll', 'awtunnel', 'awvision', 'awvoice', 'awwall', 'gawbbonet']
|
|
27
|
+
PAIRS_WITH = ['adk', 'awkno']
|
|
28
|
+
|
|
29
|
+
#: This brick's OWN config, read out of its source at generation time.
|
|
30
|
+
#: ENV_REQUIRED is `os.environ["X"]` -- absent, that is a KeyError the moment
|
|
31
|
+
#: the line runs. ENV_OPTIONAL is `os.getenv("X")`, which returns None and lets
|
|
32
|
+
#: the caller cope. Only this brick's namespace is listed: reporting the
|
|
33
|
+
#: platform-wide vars it also touches would be noise, and a doctor that floods
|
|
34
|
+
#: gets ignored.
|
|
35
|
+
ENV_REQUIRED = []
|
|
36
|
+
ENV_OPTIONAL = []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _installed(mod: str) -> "str | None":
|
|
40
|
+
"""Version if importable, else None. Never raises -- a broken sibling must
|
|
41
|
+
not take the diagnostic down with it."""
|
|
42
|
+
try:
|
|
43
|
+
if importlib.util.find_spec(mod) is None:
|
|
44
|
+
return None
|
|
45
|
+
except (ImportError, ValueError):
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
49
|
+
try:
|
|
50
|
+
return version(mod)
|
|
51
|
+
except PackageNotFoundError:
|
|
52
|
+
return "installed"
|
|
53
|
+
except Exception:
|
|
54
|
+
return "installed"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def report(out=None) -> int:
|
|
58
|
+
"""Print the stack picture. 0 = this brick and its pairs are present."""
|
|
59
|
+
out = out or sys.stdout
|
|
60
|
+
print(f"{SELF} doctor", file=out)
|
|
61
|
+
|
|
62
|
+
mine = _installed(SELF)
|
|
63
|
+
print(f" self {SELF} {mine or 'NOT IMPORTABLE'}", file=out)
|
|
64
|
+
shim = shutil.which(SELF)
|
|
65
|
+
print(f" command {shim or 'not on PATH'}", file=out)
|
|
66
|
+
|
|
67
|
+
# The stack. Siblings this brick pairs with are called out separately,
|
|
68
|
+
# because a missing pair is a REASON, while a missing unrelated brick is
|
|
69
|
+
# just a fact about your machine.
|
|
70
|
+
missing_pairs, present = [], []
|
|
71
|
+
for name in FAMILY:
|
|
72
|
+
v = _installed(name)
|
|
73
|
+
if v:
|
|
74
|
+
present.append(name)
|
|
75
|
+
elif name in PAIRS_WITH:
|
|
76
|
+
missing_pairs.append(name)
|
|
77
|
+
print(f" stack {len(present)}/{len(FAMILY)} aw* packages installed",
|
|
78
|
+
file=out)
|
|
79
|
+
if present:
|
|
80
|
+
print(f" {' '.join(sorted(present))}", file=out)
|
|
81
|
+
|
|
82
|
+
missing_req = [v for v in ENV_REQUIRED if not os.environ.get(v)]
|
|
83
|
+
if ENV_REQUIRED or ENV_OPTIONAL:
|
|
84
|
+
have = sum(1 for v in ENV_REQUIRED + ENV_OPTIONAL if os.environ.get(v))
|
|
85
|
+
total = len(ENV_REQUIRED) + len(ENV_OPTIONAL)
|
|
86
|
+
print(f" config {have}/{total} of this brick's own vars set", file=out)
|
|
87
|
+
if missing_req:
|
|
88
|
+
# Not a preference. os.environ[...] raises the moment it runs.
|
|
89
|
+
print(f" MISSING REQUIRED: {' '.join(missing_req)}", file=out)
|
|
90
|
+
|
|
91
|
+
local = _local_checks()
|
|
92
|
+
for line in local:
|
|
93
|
+
print(f" {line}", file=out)
|
|
94
|
+
|
|
95
|
+
if mine is None:
|
|
96
|
+
print(f"\nverdict: {SELF} itself is not importable. Reinstall it before "
|
|
97
|
+
f"anything else here means much.", file=out)
|
|
98
|
+
return 1
|
|
99
|
+
if missing_req:
|
|
100
|
+
print(f"\nverdict: {SELF} is missing required config "
|
|
101
|
+
f"({', '.join(missing_req)}). Those are read with os.environ[...], "
|
|
102
|
+
f"so the code path that needs them raises rather than degrades.",
|
|
103
|
+
file=out)
|
|
104
|
+
return 1
|
|
105
|
+
if missing_pairs:
|
|
106
|
+
print(f"\nverdict: {SELF} works, but pairs with "
|
|
107
|
+
f"{', '.join(sorted(missing_pairs))} which "
|
|
108
|
+
f"{'is' if len(missing_pairs) == 1 else 'are'} not installed. "
|
|
109
|
+
f"That is a capability you are missing, not an error.", file=out)
|
|
110
|
+
return 0
|
|
111
|
+
print(f"\nverdict: {SELF} and everything it pairs with are present.", file=out)
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _local_checks() -> "list[str]":
|
|
116
|
+
"""Per-brick checks, if this package defines them.
|
|
117
|
+
|
|
118
|
+
Kept as a HOOK rather than generated guesses: the generator knows the family
|
|
119
|
+
from the registry, but it does not know what awclassify needs at runtime, and a
|
|
120
|
+
doctor that invented config requirements would be confidently wrong. A
|
|
121
|
+
package supplies `_doctor_local()` returning display lines; absent, the
|
|
122
|
+
stack picture above still stands on its own.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
mod = importlib.import_module(f"{SELF}.doctor_local")
|
|
126
|
+
except Exception:
|
|
127
|
+
return []
|
|
128
|
+
try:
|
|
129
|
+
return list(mod._doctor_local())
|
|
130
|
+
except Exception as exc: # noqa: BLE001
|
|
131
|
+
return [f"local checks raised {type(exc).__name__}: {exc}"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
135
|
+
# --self-test delegates to a SIBLING module when one exists.
|
|
136
|
+
#
|
|
137
|
+
# This file is generated and a fresh run replaces it, so a self-test
|
|
138
|
+
# written HERE is deleted by the next regeneration. awdelphi learned that
|
|
139
|
+
# the expensive way: 125 lines exercising four real failure paths --
|
|
140
|
+
# convergence, roster anonymization, resume, gateway-down -- lived in this
|
|
141
|
+
# file and were destroyed by a routine regeneration, silently, leaving a
|
|
142
|
+
# --self-test flag that reported PASS while asserting nothing.
|
|
143
|
+
#
|
|
144
|
+
# So the seam is a separate module the generator never writes. A package
|
|
145
|
+
# with real machinery to prove puts it in _selftest.py; everything else
|
|
146
|
+
# keeps the honest answer below rather than a self-test that only ever
|
|
147
|
+
# passes.
|
|
148
|
+
argv = list(argv if argv is not None else __import__("sys").argv[1:])
|
|
149
|
+
if "--self-test" in argv:
|
|
150
|
+
try:
|
|
151
|
+
from . import _selftest as _st
|
|
152
|
+
except Exception:
|
|
153
|
+
print("no _selftest module: this doctor reports the stack, and has",
|
|
154
|
+
"no machinery of its own to prove")
|
|
155
|
+
return 0
|
|
156
|
+
return int(_st.run())
|
|
157
|
+
return report()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
raise SystemExit(main())
|
awclassify/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""awclassify CLI.
|
|
2
|
+
|
|
3
|
+
awclassify classify <file> [--server URL] [--token T] [--json]
|
|
4
|
+
awclassify taxonomy [--server URL] [--json]
|
|
5
|
+
|
|
6
|
+
`classify` always runs the deterministic local rules first (offline, never
|
|
7
|
+
raises). With `--server` the document is also posted to a classify-shaped
|
|
8
|
+
surface for the full LLM-powered classification — and the server's answer
|
|
9
|
+
is the one printed when it arrives. Without a server (or when the server is
|
|
10
|
+
unreachable) the rules classification is printed with an explicit "not
|
|
11
|
+
verified" warning: rules never assert "public", and `classified: False`
|
|
12
|
+
means you must not ship the document as public on this output alone.
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 a classification was produced; 1 the file could not be read
|
|
15
|
+
or `--require-server` was given and the surface was unreachable.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from awclassify import __version__
|
|
24
|
+
from awclassify.client import ClassifyClient
|
|
25
|
+
from awclassify.rules import classify_rules
|
|
26
|
+
from awclassify.taxonomy import AUDIENCES, DOC_TYPES, VISIBILITIES
|
|
27
|
+
|
|
28
|
+
MAX_READ_CHARS = 500_000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_document(path: str) -> str:
|
|
32
|
+
data = Path(path).read_bytes()
|
|
33
|
+
# Decode as UTF-8 with a tolerant fallback (decks and reports are usually
|
|
34
|
+
# UTF-8; a binary file produces a rules-only result with a clear reason).
|
|
35
|
+
try:
|
|
36
|
+
text = data.decode("utf-8")
|
|
37
|
+
except UnicodeDecodeError:
|
|
38
|
+
text = data.decode("utf-8", errors="replace")
|
|
39
|
+
return text[:MAX_READ_CHARS]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_human(result: dict, warned: bool) -> None:
|
|
43
|
+
print(f"doc_type {result.get('doc_type', 'other')}")
|
|
44
|
+
print(f"visibility {result.get('visibility', 'internal')}")
|
|
45
|
+
print(f"audience {result.get('audience', 'other')}")
|
|
46
|
+
print(f"topics {', '.join(result.get('topics') or []) or '-'}")
|
|
47
|
+
print(f"engine {result.get('engine', 'none')} "
|
|
48
|
+
f"classified={result.get('classified', False)}")
|
|
49
|
+
if result.get("reasons"):
|
|
50
|
+
print("reasons")
|
|
51
|
+
for r in result["reasons"]:
|
|
52
|
+
print(f" - {r}")
|
|
53
|
+
if warned:
|
|
54
|
+
print("\nNOT VERIFIED: rules-only classification. Pass --server for the full")
|
|
55
|
+
print("engine. Per the fail-closed contract, do not publish on this alone.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_classify(args) -> int:
|
|
59
|
+
try:
|
|
60
|
+
content = _read_document(args.file)
|
|
61
|
+
except OSError as e:
|
|
62
|
+
print(f"awclassify: cannot read {args.file}: {e}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
result = classify_rules(content, filename=Path(args.file).name)
|
|
66
|
+
warned = False
|
|
67
|
+
|
|
68
|
+
if args.server:
|
|
69
|
+
client = ClassifyClient(surface=args.server, token=args.token,
|
|
70
|
+
internal_key=args.internal_key)
|
|
71
|
+
server = client.classify(content, title=args.title,
|
|
72
|
+
source_type=args.source_type)
|
|
73
|
+
if server.get("classified"):
|
|
74
|
+
result = server # the server's verified answer wins
|
|
75
|
+
else:
|
|
76
|
+
warned = True
|
|
77
|
+
result = server if server.get("engine") != "none" else result
|
|
78
|
+
if args.require_server:
|
|
79
|
+
for r in server.get("reasons", []):
|
|
80
|
+
print(f"awclassify: server: {r}", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
else:
|
|
83
|
+
warned = True # rules-only is always "not verified"
|
|
84
|
+
|
|
85
|
+
if args.json:
|
|
86
|
+
print(json.dumps(result, indent=2))
|
|
87
|
+
else:
|
|
88
|
+
_print_human(result, warned)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_taxonomy(args) -> int:
|
|
93
|
+
local = {"doc_types": DOC_TYPES, "visibilities": VISIBILITIES,
|
|
94
|
+
"audiences": AUDIENCES}
|
|
95
|
+
if args.server:
|
|
96
|
+
# Fetch the server's taxonomy (public endpoint) and report drift.
|
|
97
|
+
import urllib.error
|
|
98
|
+
import urllib.request
|
|
99
|
+
try:
|
|
100
|
+
req = urllib.request.Request(args.server.rstrip("/") + "/taxonomy") # noqa: S310 — user-configured
|
|
101
|
+
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
|
|
102
|
+
server = json.loads(resp.read().decode("utf-8"))
|
|
103
|
+
for key in local:
|
|
104
|
+
if local[key] != server.get(key, []):
|
|
105
|
+
print(f"warning: {key} differs from the server", file=sys.stderr)
|
|
106
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
|
|
107
|
+
print(f"awclassify: cannot fetch server taxonomy: {e}", file=sys.stderr)
|
|
108
|
+
if args.json:
|
|
109
|
+
print(json.dumps(local, indent=2))
|
|
110
|
+
else:
|
|
111
|
+
for key in local:
|
|
112
|
+
print(f"{key}: {', '.join(local[key])}")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv=None) -> int:
|
|
117
|
+
# GENERATED doctor intercept (gen_aw_doctor.py) -- do not edit
|
|
118
|
+
_dv = locals().get("argv")
|
|
119
|
+
if (_dv if _dv is not None else __import__("sys").argv[1:])[:1] == ["doctor"]:
|
|
120
|
+
from ._doctor import report
|
|
121
|
+
return report()
|
|
122
|
+
# GENERATED repo-state intercept (gen_aw_doctor.py) -- do not edit
|
|
123
|
+
try:
|
|
124
|
+
from awgit import state as _aw_state
|
|
125
|
+
except Exception:
|
|
126
|
+
_aw_state = None
|
|
127
|
+
if _aw_state is not None:
|
|
128
|
+
_sv = locals().get("argv")
|
|
129
|
+
if _aw_state.cli_banner(_sv if _sv is not None else __import__("sys").argv[1:]):
|
|
130
|
+
return 0
|
|
131
|
+
parser = argparse.ArgumentParser(
|
|
132
|
+
prog="awclassify",
|
|
133
|
+
description="Classify a document: type, visibility, audience, topics.",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument("--version", action="version", version=f"awclassify {__version__}")
|
|
136
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
137
|
+
|
|
138
|
+
c = sub.add_parser("classify", help="classify a document")
|
|
139
|
+
c.add_argument("file")
|
|
140
|
+
c.add_argument("--server", default="", help="classify-shaped server URL")
|
|
141
|
+
c.add_argument("--token", default="", help="Bearer token for the server")
|
|
142
|
+
c.add_argument("--internal-key", default="", dest="internal_key",
|
|
143
|
+
help="X-Internal-Key for a self-hosted platform server")
|
|
144
|
+
c.add_argument("--title", default="")
|
|
145
|
+
c.add_argument("--source-type", default="", dest="source_type")
|
|
146
|
+
c.add_argument("--require-server", action="store_true",
|
|
147
|
+
help="exit 1 when the server is unreachable")
|
|
148
|
+
c.add_argument("--json", action="store_true")
|
|
149
|
+
c.set_defaults(func=cmd_classify)
|
|
150
|
+
|
|
151
|
+
t = sub.add_parser("taxonomy", help="print the fixed vocabulary")
|
|
152
|
+
t.add_argument("--server", default="")
|
|
153
|
+
t.add_argument("--json", action="store_true")
|
|
154
|
+
t.set_defaults(func=cmd_taxonomy)
|
|
155
|
+
|
|
156
|
+
args = parser.parse_args(argv)
|
|
157
|
+
return args.func(args)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
sys.exit(main())
|
awclassify/client.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Client for a classify-shaped server.
|
|
2
|
+
|
|
3
|
+
Zero dependencies (stdlib urllib). Speaks the contract of the platform's
|
|
4
|
+
`/classify` surface — POST a document, get back the classification — and of
|
|
5
|
+
any server that implements the same shape:
|
|
6
|
+
|
|
7
|
+
POST /classify
|
|
8
|
+
{"content": "...", "title": "...", "source_type": "..."}
|
|
9
|
+
-> {"classification": {doc_type, visibility, audience, topics, ...}}
|
|
10
|
+
|
|
11
|
+
Auth is optional: a Bearer token (platform sessions), or an internal key
|
|
12
|
+
(sent as X-Internal-Key + X-Caller-Type: platform for self-hosted platform
|
|
13
|
+
servers). On ANY failure the client returns the fail-closed shape
|
|
14
|
+
(classified=False, visibility="internal") with the reason — it never
|
|
15
|
+
fabricates a classification.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from typing import Any, Dict
|
|
22
|
+
|
|
23
|
+
DEFAULT_SURFACE = "http://127.0.0.1:8001/doc-classify"
|
|
24
|
+
# NOT /classify: on the AitherOS platform that path is the INTENT classifier's
|
|
25
|
+
# (first handler wins, measured 2026-08-29) — the document surface is
|
|
26
|
+
# /doc-classify. Any classify-shaped server may of course use any path.
|
|
27
|
+
|
|
28
|
+
FAIL_CLOSED = {
|
|
29
|
+
"classified": False,
|
|
30
|
+
"engine": "none",
|
|
31
|
+
"visibility": "internal",
|
|
32
|
+
"reasons": ["client: surface unreachable"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ClassifyClient:
|
|
37
|
+
"""Minimal client for POST /classify on a classify-shaped server."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, surface: str = "", token: str = "",
|
|
40
|
+
internal_key: str = "", timeout: float = 200.0):
|
|
41
|
+
# 200s default: a classify-shaped server's LLM round trip is minutes,
|
|
42
|
+
# not seconds (measured live 2026-08-29 on the platform: ~2 min).
|
|
43
|
+
self.surface = (surface or DEFAULT_SURFACE).rstrip("/")
|
|
44
|
+
self.token = token
|
|
45
|
+
self.internal_key = internal_key
|
|
46
|
+
self.timeout = timeout
|
|
47
|
+
|
|
48
|
+
def classify(
|
|
49
|
+
self,
|
|
50
|
+
content: str,
|
|
51
|
+
*,
|
|
52
|
+
title: str = "",
|
|
53
|
+
source_type: str = "",
|
|
54
|
+
filename: str = "",
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
"""Classify a document. Fail-closed shape on ANY failure."""
|
|
57
|
+
payload = json.dumps({
|
|
58
|
+
"content": content,
|
|
59
|
+
"title": title,
|
|
60
|
+
"source_type": source_type,
|
|
61
|
+
"filename": filename,
|
|
62
|
+
}).encode("utf-8")
|
|
63
|
+
req = urllib.request.Request(
|
|
64
|
+
self.surface,
|
|
65
|
+
data=payload,
|
|
66
|
+
headers={
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
},
|
|
70
|
+
method="POST",
|
|
71
|
+
)
|
|
72
|
+
if self.token:
|
|
73
|
+
req.add_header("Authorization", f"Bearer {self.token}")
|
|
74
|
+
if self.internal_key:
|
|
75
|
+
req.add_header("X-Internal-Key", self.internal_key)
|
|
76
|
+
req.add_header("X-Caller-Type", "platform")
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310 — surface is user-configured
|
|
79
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
80
|
+
if isinstance(data, dict) and isinstance(data.get("classification"), dict):
|
|
81
|
+
return data["classification"]
|
|
82
|
+
if isinstance(data, dict) and "doc_type" in data:
|
|
83
|
+
return data
|
|
84
|
+
return dict(FAIL_CLOSED, reasons=["client: unexpected response shape"])
|
|
85
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as e:
|
|
86
|
+
return dict(FAIL_CLOSED, reasons=[f"client: {type(e).__name__}: {e}"])
|
awclassify/rules.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Deterministic local classifier — the offline tier of awclassify.
|
|
2
|
+
|
|
3
|
+
A standalone twin of the platform engine's rules path
|
|
4
|
+
(`lib.knowledge.DocClassifier.rules_classify`), deliberately without any
|
|
5
|
+
monorepo import so this package ships to strangers. The parity gate keeps
|
|
6
|
+
the taxonomy in step; the rules here are the same shape and intent.
|
|
7
|
+
|
|
8
|
+
CONTRACT:
|
|
9
|
+
- `classified` is always False in the rules result: rules are heuristics,
|
|
10
|
+
not verification — a stranger must not ship on them.
|
|
11
|
+
- visibility NEVER becomes "public" from a filename alone. An explicit
|
|
12
|
+
PUBLIC marker is required.
|
|
13
|
+
- every decision is recorded in `reasons`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Dict, List
|
|
18
|
+
|
|
19
|
+
#: Explicit markers that let the rules path assert "public" — the ONLY
|
|
20
|
+
#: filename/source evidence this module trusts for publicity.
|
|
21
|
+
PUBLIC_MARKERS = (".public.", "public/", "PUBLIC.md", "-public")
|
|
22
|
+
|
|
23
|
+
_TYPE_RULES = [
|
|
24
|
+
(re.compile(r"\.deck\.json$|_deck\.py$|\.pptx?$|slides", re.I), "deck"),
|
|
25
|
+
(re.compile(r"transcript|session-log|conversation-log|chat-log", re.I), "transcript"),
|
|
26
|
+
(re.compile(r"\bprd\b|\.prd|product-requirements|requirements\.md", re.I), "prd"),
|
|
27
|
+
(re.compile(r"decision|adr-|ADR_", re.I), "decision"),
|
|
28
|
+
(re.compile(r"^blog|/blog|posts?/", re.I), "blog"),
|
|
29
|
+
(re.compile(r"minutes|meeting-notes|notes\.md", re.I), "minutes"),
|
|
30
|
+
(re.compile(r"^docs?/|documentation|manual|guide", re.I), "docs"),
|
|
31
|
+
(re.compile(r"contract|agreement|terms|license", re.I), "contract"),
|
|
32
|
+
(re.compile(r"\.(py|ts|tsx|js|go|rs|java|c|cpp|sh)$", re.I), "code"),
|
|
33
|
+
(re.compile(r"\.(csv|json|yaml|yml|xml|parquet|sql)$", re.I), "data"),
|
|
34
|
+
(re.compile(r"research|experiment|benchmark|evaluation", re.I), "research"),
|
|
35
|
+
(re.compile(r"report|summary|status", re.I), "report"),
|
|
36
|
+
(re.compile(r"spec|api[-_.]?doc|design[-_.]?doc", re.I), "spec"),
|
|
37
|
+
(re.compile(r"memo|brief", re.I), "memo"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_AUDIENCE_RULES = [
|
|
41
|
+
(re.compile(r"^customers?/|customer-facing|sales", re.I), "customers"),
|
|
42
|
+
(re.compile(r"investor|pitch|fundraising|cap-table", re.I), "investors"),
|
|
43
|
+
(re.compile(r"partner|integrat", re.I), "partners"),
|
|
44
|
+
(re.compile(r"regulatory|compliance|audit", re.I), "regulatory"),
|
|
45
|
+
(re.compile(r"^internal|internal-", re.I), "internal"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def classify_rules(
|
|
50
|
+
content: str = "",
|
|
51
|
+
*,
|
|
52
|
+
filename: str = "",
|
|
53
|
+
title: str = "",
|
|
54
|
+
source_type: str = "",
|
|
55
|
+
) -> Dict:
|
|
56
|
+
"""Deterministic classification from filenames, titles and source types.
|
|
57
|
+
|
|
58
|
+
Zero I/O, zero network, zero dependencies. Never raises. Always returns
|
|
59
|
+
the full result shape with `classified: False` and `reasons` — the same
|
|
60
|
+
shape a classify-shaped server returns, so consumers handle one contract.
|
|
61
|
+
"""
|
|
62
|
+
haystack = " ".join(filter(None, [filename, title, source_type]))
|
|
63
|
+
reasons: List[str] = []
|
|
64
|
+
|
|
65
|
+
doc_type = "other"
|
|
66
|
+
for pat, t in _TYPE_RULES:
|
|
67
|
+
if pat.search(haystack):
|
|
68
|
+
doc_type = t
|
|
69
|
+
reasons.append(f"rules: type={t} (matched {pat.pattern})")
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
audience = "other"
|
|
73
|
+
for pat, a in _AUDIENCE_RULES:
|
|
74
|
+
if pat.search(haystack):
|
|
75
|
+
audience = a
|
|
76
|
+
reasons.append(f"rules: audience={a}")
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
topics: List[str] = []
|
|
80
|
+
if any(k in haystack.lower() for k in ("revenue", "sales", "deal", "customer")):
|
|
81
|
+
topics.append("business")
|
|
82
|
+
if any(k in haystack.lower() for k in ("agent", "ai", "llm", "model")):
|
|
83
|
+
topics.append("ai")
|
|
84
|
+
|
|
85
|
+
visibility = "internal"
|
|
86
|
+
if any(m in haystack for m in PUBLIC_MARKERS):
|
|
87
|
+
visibility = "public"
|
|
88
|
+
reasons.append("rules: visibility=public (explicit PUBLIC marker)")
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"doc_type": doc_type,
|
|
92
|
+
"visibility": visibility,
|
|
93
|
+
"audience": audience,
|
|
94
|
+
"topics": topics,
|
|
95
|
+
"confidence": 0.55 if reasons else 0.0,
|
|
96
|
+
"engine": "rules",
|
|
97
|
+
"classified": False,
|
|
98
|
+
"reasons": reasons or ["rules: no evidence matched"],
|
|
99
|
+
}
|
awclassify/taxonomy.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The fixed classification vocabulary — standalone copy.
|
|
2
|
+
|
|
3
|
+
This file is the PUBLIC copy of the taxonomy. The platform engine's copy
|
|
4
|
+
lives in `lib.knowledge.DocClassifier`; the parity gate
|
|
5
|
+
(`check_classify_taxonomy_parity.py`) asserts the two stay in step, because
|
|
6
|
+
two copies of a rule set drift — the same shape as the shared browser
|
|
7
|
+
inference worker, and the reason that lesson is a gate here rather than a
|
|
8
|
+
comment.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
DOC_TYPES = [
|
|
12
|
+
"deck", "blog", "prd", "report", "transcript", "memo", "decision",
|
|
13
|
+
"spec", "docs", "guide", "code", "data", "email", "social", "minutes",
|
|
14
|
+
"contract", "research", "other",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
VISIBILITIES = ["public", "internal", "confidential"]
|
|
18
|
+
|
|
19
|
+
AUDIENCES = [
|
|
20
|
+
"customers", "investors", "partners", "general_public", "internal",
|
|
21
|
+
"agents", "regulatory", "other",
|
|
22
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awclassify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Classify any document — type, visibility, audience, topics. Aither World Classify.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/Aitherium/awclassify
|
|
7
|
+
Project-URL: Documentation, https://github.com/Aitherium/awclassify#readme
|
|
8
|
+
Project-URL: Repository, https://github.com/Aitherium/awclassify.git
|
|
9
|
+
Project-URL: Issues, https://github.com/Aitherium/awclassify/issues
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# awclassify
|
|
18
|
+
|
|
19
|
+
**Aither World Classify** — classify any document: *what it is*, *who may
|
|
20
|
+
read it*, *who it is for*, *what it is about*. A standalone, zero-dependency
|
|
21
|
+
CLI + client, public and pip-installable, for the Aither World stack.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install awclassify
|
|
25
|
+
|
|
26
|
+
awclassify classify report.md
|
|
27
|
+
# doc_type report
|
|
28
|
+
# visibility internal
|
|
29
|
+
# audience other
|
|
30
|
+
# topics -
|
|
31
|
+
# engine rules classified=False
|
|
32
|
+
#
|
|
33
|
+
# NOT VERIFIED: rules-only classification. Pass --server for the full
|
|
34
|
+
# engine. Per the fail-closed contract, do not publish on this alone.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## What it answers
|
|
38
|
+
|
|
39
|
+
| field | meaning | vocabulary |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `doc_type` | what it is | deck, blog, prd, transcript, report, memo, decision, spec, docs, guide, code, data, email, social, minutes, contract, research, other |
|
|
42
|
+
| `visibility` | who may read it | public, internal, confidential |
|
|
43
|
+
| `audience` | who it is for | customers, investors, partners, general_public, internal, agents, regulatory, other |
|
|
44
|
+
| `topics` | what it is about | 1–5 free-form tags |
|
|
45
|
+
|
|
46
|
+
## The fail-closed contract
|
|
47
|
+
|
|
48
|
+
A classification you cannot verify is **NOT public**.
|
|
49
|
+
|
|
50
|
+
- `classified: False` means the output is heuristic only — do not publish
|
|
51
|
+
the document on it.
|
|
52
|
+
- The rules path never asserts `public` from a filename alone; an explicit
|
|
53
|
+
marker (`public/`, `.public.`) is required.
|
|
54
|
+
- When the server cannot be reached, the client returns
|
|
55
|
+
`classified: False, visibility: internal` with the reason — never a
|
|
56
|
+
fabricated answer.
|
|
57
|
+
|
|
58
|
+
To publish a document you need the server-verified answer:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
awclassify classify deck.json --server https://your-platform/doc-classify \
|
|
62
|
+
--token "$PLATFORM_BEARER" --json | jq .visibility
|
|
63
|
+
# "public" ← only now may a stranger read it
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Modes
|
|
67
|
+
|
|
68
|
+
- **Local rules** (default, offline): deterministic classification from the
|
|
69
|
+
filename, title and source type. Zero network, zero dependencies.
|
|
70
|
+
- **Server**: `--server URL` posts the document to any classify-shaped
|
|
71
|
+
surface — the AitherOS platform's `/classify`, or anything implementing
|
|
72
|
+
the same contract — for the full LLM-powered classification with
|
|
73
|
+
confidence scores. `--require-server` makes an unreachable surface an
|
|
74
|
+
error instead of a downgrade.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
awclassify taxonomy # the fixed vocabulary, offline
|
|
78
|
+
awclassify taxonomy --server URL # ...and diff it against the server's
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Why it exists
|
|
82
|
+
|
|
83
|
+
A $35K revenue figure shipped in a public slide deck because nothing asked
|
|
84
|
+
what the document was or who could see it. `awclassify` makes that answer
|
|
85
|
+
exist as data before anything ships — one command, no credentials required
|
|
86
|
+
for the local tier.
|
|
87
|
+
|
|
88
|
+
## Relationship to the platform
|
|
89
|
+
|
|
90
|
+
`awclassify` is the **client brick**. The service half — the engine
|
|
91
|
+
(`lib.knowledge.DocClassifier`), the genesis `/doc-classify` router, and the
|
|
92
|
+
Nexus-ingest integration — lives in the AitherOS platform as
|
|
93
|
+
`aitherclassify`. The taxonomy parity between the two copies is asserted by
|
|
94
|
+
the platform's `check_classify_taxonomy_parity` gate.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m pytest tests/
|
|
100
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
awclassify/__init__.py,sha256=sO5_uMVo65LFAtu2_kkwTWp3gS2y3Xj4H5nUWVQv8OY,1173
|
|
2
|
+
awclassify/__main__.py,sha256=FhGrTfO9jinZ1AcPqZFMZoQk-ohLwtMIvoFzqFn7lHU,166
|
|
3
|
+
awclassify/_doctor.py,sha256=78QDx0XSKPbiqGSqgFh2ArfUttgVZ5hE7XrdSXJ5LEs,6995
|
|
4
|
+
awclassify/cli.py,sha256=O-CoUrazHmHdLMHGVzuo0fPOCcO19X8HnOv-PYc7ijw,6385
|
|
5
|
+
awclassify/client.py,sha256=RES5deg_7sdwlL-SSbWrM0op7dTk7vqERc_-eOcej_I,3372
|
|
6
|
+
awclassify/rules.py,sha256=rUnGTn-eUuwzXVnynUw-wgJRYawQ1CIvLB1bOp2-j0k,3938
|
|
7
|
+
awclassify/taxonomy.py,sha256=In6gMUX6G3Xu_8YxgbTST1uJ1Cgp3U4fmmgU4YHpJxw,806
|
|
8
|
+
awclassify-0.1.0.dist-info/licenses/LICENSE,sha256=cikyzku070-A3VkcJ9zoVGJtGJZxIXNeAfVBP4iinRw,570
|
|
9
|
+
awclassify-0.1.0.dist-info/METADATA,sha256=WPFw11lFtPTtx5rT2g9qRXty5ZGANoQR7OeZ1dqr_OU,3687
|
|
10
|
+
awclassify-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
awclassify-0.1.0.dist-info/entry_points.txt,sha256=E9G2BO4g9SXQINOEBfR7yr8nLqMBRxDAkDtJPLWkC78,51
|
|
12
|
+
awclassify-0.1.0.dist-info/top_level.txt,sha256=f4EXkLwUjkcnQe6KikM18aimFNwH0tspMFQx_zOyGRU,11
|
|
13
|
+
awclassify-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache License 2.0
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Aitherium
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
awclassify
|