rithik 0.1.1__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.
rithik/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """rithik: a command-line card for Rithik Krishna T, and an offline scam-message checker."""
2
+
3
+ __version__ = "0.1.1"
rithik/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m rithik` behaves exactly like the `rithik` script, for machines without it on PATH."""
2
+
3
+ from rithik.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
rithik/_term.py ADDED
@@ -0,0 +1,106 @@
1
+ """Terminal plumbing shared by the card and the scam report: colour, and output that never crashes.
2
+
3
+ The people most likely to paste a scam message into this tool are on stock Windows consoles,
4
+ so the defaults here are chosen for cmd.exe and PowerShell 5.1 first.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import codecs
10
+ import os
11
+ from typing import TextIO
12
+
13
+ RESET = "\033[0m"
14
+ BOLD = "1"
15
+ DIM = "2"
16
+ RED = "1;31"
17
+ GREEN = "1;32"
18
+ YELLOW = "1;33"
19
+ CYAN = "36"
20
+ BOLD_CYAN = "1;36"
21
+
22
+ _ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
23
+ _STD_OUTPUT_HANDLE = -11
24
+
25
+
26
+ def paint(text: str, code: str, color: bool) -> str:
27
+ if not color or not text:
28
+ return text
29
+ return f"\033[{code}m{text}{RESET}"
30
+
31
+
32
+ def enable_windows_ansi() -> bool:
33
+ """Turn on escape-code handling for this console; False means colour must stay off."""
34
+ try:
35
+ import ctypes
36
+ from ctypes import wintypes
37
+ except (ImportError, ValueError):
38
+ # Some older Pythons refuse to import wintypes off Windows at all.
39
+ return False
40
+ try:
41
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
42
+ kernel32.GetStdHandle.restype = wintypes.HANDLE
43
+ kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
44
+ kernel32.GetConsoleMode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
45
+ kernel32.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
46
+
47
+ handle = kernel32.GetStdHandle(wintypes.DWORD(_STD_OUTPUT_HANDLE).value)
48
+ mode = wintypes.DWORD()
49
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
50
+ return False
51
+ if mode.value & _ENABLE_VIRTUAL_TERMINAL_PROCESSING:
52
+ return True
53
+ return bool(
54
+ kernel32.SetConsoleMode(handle, mode.value | _ENABLE_VIRTUAL_TERMINAL_PROCESSING)
55
+ )
56
+ except (OSError, AttributeError, ValueError, TypeError, ctypes.ArgumentError):
57
+ # Old conhost, a missing DLL, a sandbox: any failure just means plain text, never a crash.
58
+ return False
59
+
60
+
61
+ def color_enabled(stream: TextIO, disabled: bool) -> bool:
62
+ # no-color.org: the variable counts only when it is set to something non-empty.
63
+ if disabled or os.environ.get("NO_COLOR"):
64
+ return False
65
+ try:
66
+ if not stream.isatty():
67
+ return False
68
+ except (AttributeError, ValueError, OSError):
69
+ return False
70
+ if os.name == "nt":
71
+ # Without VT processing the console prints the escape codes literally, which is worse
72
+ # than no colour at all.
73
+ return enable_windows_ansi()
74
+ return os.environ.get("TERM") != "dumb"
75
+
76
+
77
+ def prepare(stream: TextIO | None) -> None:
78
+ # A console that cannot encode a character (cp437, cp1252) would otherwise raise
79
+ # UnicodeEncodeError halfway through a report; a "?" in its place is the lesser harm.
80
+ reconfigure = getattr(stream, "reconfigure", None)
81
+ if reconfigure is None:
82
+ return
83
+ try:
84
+ reconfigure(errors="replace")
85
+ except (ValueError, OSError, TypeError):
86
+ pass
87
+
88
+
89
+ def write(stream: TextIO, text: str) -> None:
90
+ try:
91
+ stream.write(text)
92
+ except UnicodeEncodeError:
93
+ # Streams without reconfigure() (wrappers some IDEs install) still get the text, with
94
+ # the characters they cannot hold replaced.
95
+ encoding = _encoding_of(stream)
96
+ stream.write(text.encode(encoding, errors="replace").decode(encoding))
97
+ stream.flush()
98
+
99
+
100
+ def _encoding_of(stream: TextIO) -> str:
101
+ encoding = getattr(stream, "encoding", None) or "ascii"
102
+ try:
103
+ codecs.lookup(encoding)
104
+ except LookupError:
105
+ return "ascii"
106
+ return encoding
rithik/card.py ADDED
@@ -0,0 +1,154 @@
1
+ """The name card printed by `rithik` and `rithik card`.
2
+
3
+ Every fact here is taken from a public page: the GitHub profile README and the READMEs of the
4
+ public repos listed. Numbers are quoted as those pages state them, so the card never claims more
5
+ than a reader can check, and nothing links to a private repo.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import textwrap
12
+
13
+ from rithik._term import BOLD, BOLD_CYAN, CYAN, DIM, paint
14
+
15
+ NAME = "Rithik Krishna T"
16
+ TAGLINE = "AI/ML, Android and security. I build complete systems and measure them."
17
+
18
+ EDUCATION = (
19
+ {
20
+ "degree": "B.Tech CSE (Data Science)",
21
+ "institution": "MGIT Hyderabad",
22
+ "graduation": "May 2027",
23
+ },
24
+ {
25
+ "degree": "BS Data Science & Applications",
26
+ "institution": "IIT Madras",
27
+ "graduation": "May 2027",
28
+ },
29
+ )
30
+
31
+ EXPERIENCE = ({"role": "AI/ML Intern", "organisation": "HCLTech", "period": "Sep 2025 - Jan 2026"},)
32
+
33
+ LINKS = {
34
+ "github": "https://github.com/Daemon-VI",
35
+ "linkedin": "https://www.linkedin.com/in/rithikkrishnat/",
36
+ "email": "trithikkrishna@gmail.com",
37
+ }
38
+
39
+ PROJECTS = (
40
+ {
41
+ "name": "AegisToolkit",
42
+ "summary": "31-tool Android security toolkit, no root required",
43
+ "url": "https://github.com/Daemon-VI/AegisToolkit",
44
+ },
45
+ {
46
+ "name": "Cascade",
47
+ "summary": "Drag-and-drop Android automation with 154 actions",
48
+ "url": "https://github.com/Daemon-VI/Cascade",
49
+ },
50
+ {
51
+ "name": "optivision-rag",
52
+ "summary": (
53
+ "Visual-token compression for VLM document retrieval: 113.5x less storage per "
54
+ "page, 86.7% of baseline nDCG@5"
55
+ ),
56
+ "url": "https://github.com/Daemon-VI/optivision-rag",
57
+ },
58
+ {
59
+ "name": "carpool",
60
+ "summary": "Shared-commute cost settlement for iOS, Android and web",
61
+ "url": "https://github.com/Daemon-VI/carpool",
62
+ },
63
+ {
64
+ "name": "darkwatch",
65
+ "summary": "Dark web exposure monitor for leak sites and breaches",
66
+ "url": "https://github.com/Daemon-VI/darkwatch",
67
+ },
68
+ {
69
+ "name": "rithik",
70
+ "summary": "This card, plus an offline Indian scam-message checker",
71
+ "url": "https://github.com/Daemon-VI/rithik",
72
+ },
73
+ )
74
+
75
+ HINT_COMMAND = 'rithik scam "<message or link>"'
76
+
77
+ WIDTH = 78
78
+ _INNER = WIDTH - 6 # each row is "| " + text + " |"
79
+ _LABEL_COL = 13
80
+ _PROJECT_COL = 17
81
+
82
+
83
+ def card_data() -> dict:
84
+ # Deep copies, so a caller that edits the result cannot change what the next render prints.
85
+ return {
86
+ "name": NAME,
87
+ "tagline": TAGLINE,
88
+ "education": copy.deepcopy(list(EDUCATION)),
89
+ "experience": copy.deepcopy(list(EXPERIENCE)),
90
+ "links": dict(LINKS),
91
+ "projects": copy.deepcopy(list(PROJECTS)),
92
+ }
93
+
94
+
95
+ def render_card(color: bool = False) -> str:
96
+ # Rows are (plain, styled) pairs: padding has to be measured on the plain text, because
97
+ # escape codes take up characters but no columns.
98
+ rows: list[tuple[str, str]] = [("", "")]
99
+ rows.append((NAME, paint(NAME, BOLD_CYAN, color)))
100
+ rows.extend(_wrapped(TAGLINE))
101
+ rows.append(("", ""))
102
+
103
+ degrees = [f"{e['degree']}, {e['institution']}" for e in EDUCATION]
104
+ graduations = {e["graduation"] for e in EDUCATION}
105
+ if len(graduations) == 1:
106
+ degrees.append(f"Both graduating {graduations.pop()}")
107
+ else:
108
+ degrees = [f"{d} ({e['graduation']})" for d, e in zip(degrees, EDUCATION)]
109
+ for i, line in enumerate(degrees):
110
+ rows.extend(_two_col("Education" if i == 0 else "", line, _LABEL_COL, CYAN, color))
111
+ for i, job in enumerate(EXPERIENCE):
112
+ line = f"{job['role']}, {job['organisation']}, {job['period']}"
113
+ rows.extend(_two_col("Experience" if i == 0 else "", line, _LABEL_COL, CYAN, color))
114
+ rows.append(("", ""))
115
+
116
+ for label, key in (("GitHub", "github"), ("LinkedIn", "linkedin"), ("Email", "email")):
117
+ rows.extend(_two_col(label, LINKS[key], _LABEL_COL, CYAN, color))
118
+ rows.append(("", ""))
119
+
120
+ heading = "Public projects"
121
+ rows.append((heading, paint(heading, BOLD, color)))
122
+ for project in PROJECTS:
123
+ rows.extend(_two_col(project["name"], project["summary"], _PROJECT_COL, BOLD, color))
124
+ rows.extend(_two_col("", project["url"], _PROJECT_COL, BOLD, color))
125
+ rows.append(("", ""))
126
+
127
+ border = paint("+" + "-" * (WIDTH - 2) + "+", DIM, color)
128
+ bar = paint("|", DIM, color)
129
+ lines = [border]
130
+ for plain, styled in rows:
131
+ lines.append(f"{bar} {styled}{' ' * (_INNER - len(plain))} {bar}")
132
+ lines.append(border)
133
+ lines.append("Try: " + paint(HINT_COMMAND, BOLD, color))
134
+ return "\n".join(lines) + "\n"
135
+
136
+
137
+ def _wrapped(text: str) -> list[tuple[str, str]]:
138
+ return [(piece, piece) for piece in _wrap(text, _INNER)]
139
+
140
+
141
+ def _two_col(
142
+ left: str, right: str, left_width: int, left_code: str, color: bool
143
+ ) -> list[tuple[str, str]]:
144
+ rows = []
145
+ for i, piece in enumerate(_wrap(right, _INNER - left_width)):
146
+ head = left if i == 0 else ""
147
+ pad = " " * (left_width - len(head))
148
+ rows.append((head + pad + piece, paint(head, left_code, color) + pad + piece))
149
+ return rows
150
+
151
+
152
+ def _wrap(text: str, width: int) -> list[str]:
153
+ # break_on_hyphens=False keeps names like "Drag-and-drop" and URLs in one piece.
154
+ return textwrap.wrap(text, width, break_on_hyphens=False) or [""]
rithik/cli.py ADDED
@@ -0,0 +1,297 @@
1
+ """Command-line entry point: `rithik`, `rithik card` and `rithik scam`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import codecs
7
+ import json
8
+ import locale
9
+ import os
10
+ import re
11
+ import sys
12
+ import textwrap
13
+ from typing import TYPE_CHECKING, TextIO
14
+
15
+ from rithik import __version__, _term
16
+ from rithik.card import card_data, render_card
17
+ from rithik.scam import analyze
18
+ from rithik.scam.model import Report, Verdict
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+ WIDTH = 78
24
+
25
+ # Messages are a few hundred bytes. The cap only stops an accidental `rithik scam - < disk.img`
26
+ # from sitting in the regexes for minutes.
27
+ STDIN_LIMIT = 1 << 20
28
+
29
+ USAGE_HINT = (
30
+ "Give rithik scam a message to check, in quotes:\n"
31
+ ' rithik scam "<message or link>"\n'
32
+ "or pipe it in:\n"
33
+ " <command> | rithik scam -\n"
34
+ )
35
+
36
+ _HEADLINES = {
37
+ Verdict.SCAM: ("LIKELY SCAM", _term.RED),
38
+ Verdict.SUSPICIOUS: ("SUSPICIOUS", _term.YELLOW),
39
+ Verdict.NO_SIGNALS: ("NO KNOWN SCAM SIGNALS", _term.GREEN),
40
+ }
41
+
42
+ _WARNING_ADVICE = (
43
+ "Do not click the links, call the numbers or install anything the message asks for.",
44
+ (
45
+ "Never share an OTP, UPI PIN or card details, and remember that you never need your UPI "
46
+ "PIN to receive money."
47
+ ),
48
+ (
49
+ "Verify through your bank's official app, or the number printed on your card, not a "
50
+ "number from the message."
51
+ ),
52
+ "If money was taken, call 1930 at once or report it at https://cybercrime.gov.in",
53
+ "Report the fraud SMS or call through Chakshu at https://sancharsaathi.gov.in",
54
+ )
55
+
56
+ _NO_SIGNALS_ADVICE = (
57
+ "None of the scam patterns this checker knows matched. That is not a guarantee that the "
58
+ "message is genuine: new scams appear all the time. Before you pay, or share an OTP or PIN, "
59
+ "verify through the official app or website."
60
+ )
61
+
62
+ _SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
63
+ _LINK_IN_TEXT = re.compile(r"(?i)\b(?:[a-z][a-z0-9+.-]*://|www\.)[^\s<>\"']+")
64
+ _EVIDENCE_LIMIT = 120
65
+
66
+
67
+ def main(argv: Sequence[str] | None = None) -> int:
68
+ out = sys.stdout
69
+ _term.prepare(out)
70
+ _term.prepare(sys.stderr)
71
+ try:
72
+ args = _build_parser().parse_args(argv)
73
+ except SystemExit as exc:
74
+ # argparse exits on --version, --help and bad usage; a caller of main() wants a code.
75
+ return _exit_code(exc)
76
+ try:
77
+ if args.command == "scam":
78
+ return _run_scam(args, out)
79
+ return _run_card(args, out)
80
+ except BrokenPipeError:
81
+ # `rithik | head` closing the pipe early is the reader's choice, not an error.
82
+ _silence_stdout()
83
+ return 0
84
+ except KeyboardInterrupt:
85
+ return 130
86
+
87
+
88
+ def render_report(report: Report, color: bool = False) -> str:
89
+ verdict = Verdict(report.verdict)
90
+ title, code = _HEADLINES[verdict]
91
+ lines = [f"{_term.paint(title, code, color)} (score {report.score:.2f} of 1.00)", ""]
92
+
93
+ if report.reasons:
94
+ lines.append(_term.paint("Why", _term.BOLD, color))
95
+ for reason in report.reasons:
96
+ text = reason.label
97
+ if reason.evidence:
98
+ # Labels are full sentences; the evidence follows a colon, so the full stop goes.
99
+ text = text.removesuffix(".")
100
+ text += f': "{_shorten(_defang_text(reason.evidence, report.urls))}"'
101
+ if reason.weight < 0:
102
+ text += " (lowers the score)"
103
+ lines.extend(_bullet(text))
104
+ lines.append("")
105
+
106
+ if report.urls:
107
+ lines.append(_term.paint("Links in the message", _term.BOLD, color))
108
+ for url in report.urls:
109
+ lines.extend(_bullet(_defang(url)))
110
+ # People copy links out of terminals, and many terminals open them on click; the
111
+ # brackets make both deliberate.
112
+ lines.append(" (dots shown as [.] so the links cannot be opened by accident)")
113
+ lines.append("")
114
+
115
+ if verdict is Verdict.NO_SIGNALS:
116
+ lines.extend(textwrap.wrap(_NO_SIGNALS_ADVICE, WIDTH))
117
+ else:
118
+ lines.append(_term.paint("What to do", _term.BOLD, color))
119
+ for tip in _WARNING_ADVICE:
120
+ lines.extend(_bullet(tip))
121
+ return "\n".join(lines) + "\n"
122
+
123
+
124
+ def _build_parser() -> argparse.ArgumentParser:
125
+ parser = argparse.ArgumentParser(
126
+ prog="rithik",
127
+ description="Rithik Krishna T's card, and an offline checker for scam messages and links.",
128
+ epilog='Examples: rithik | rithik scam "<message or link>" | rithik card --json',
129
+ )
130
+ parser.add_argument("--version", action="version", version=f"rithik {__version__}")
131
+ _add_shared_flags(parser, default=False)
132
+
133
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND")
134
+ card = commands.add_parser("card", help="print the card (what plain `rithik` does)")
135
+ _add_shared_flags(card, default=argparse.SUPPRESS)
136
+
137
+ scam = commands.add_parser(
138
+ "scam",
139
+ help="check a message or link for scam signals, offline",
140
+ description="Check a message or link for known scam patterns. Nothing leaves this machine.",
141
+ )
142
+ _add_shared_flags(scam, default=argparse.SUPPRESS)
143
+ scam.add_argument(
144
+ "text",
145
+ nargs="*",
146
+ metavar="TEXT",
147
+ help='the message or link; "-" (or no TEXT with piped input) reads standard input',
148
+ )
149
+ return parser
150
+
151
+
152
+ def _add_shared_flags(parser: argparse.ArgumentParser, default: object) -> None:
153
+ # The flags work before and after the subcommand. The subcommand copies default to
154
+ # SUPPRESS so that `rithik --no-color scam ...` is not reset to False by the subparser.
155
+ parser.add_argument(
156
+ "--json", action="store_true", default=default, help="print machine-readable JSON only"
157
+ )
158
+ parser.add_argument(
159
+ "--no-color",
160
+ action="store_true",
161
+ default=default,
162
+ help="plain text without colour (the NO_COLOR variable does the same)",
163
+ )
164
+
165
+
166
+ def _exit_code(exc: SystemExit) -> int:
167
+ if exc.code is None:
168
+ return 0
169
+ if isinstance(exc.code, int):
170
+ return exc.code
171
+ return 2
172
+
173
+
174
+ def _run_card(args: argparse.Namespace, out: TextIO) -> int:
175
+ if args.json:
176
+ _term.write(out, json.dumps(card_data(), indent=2) + "\n")
177
+ else:
178
+ _term.write(out, render_card(color=_term.color_enabled(out, args.no_color)))
179
+ return 0
180
+
181
+
182
+ def _run_scam(args: argparse.Namespace, out: TextIO) -> int:
183
+ words = list(args.text)
184
+ if words == ["-"] or (not words and not _stdin_is_interactive()):
185
+ text = _read_stdin()
186
+ elif words:
187
+ text = " ".join(words)
188
+ else:
189
+ _term.write(sys.stderr, USAGE_HINT)
190
+ return 2
191
+
192
+ if not text.strip():
193
+ _term.write(sys.stderr, "rithik: the message was empty, so there is nothing to check.\n")
194
+ _term.write(sys.stderr, USAGE_HINT)
195
+ return 2
196
+
197
+ report = analyze(text)
198
+ if args.json:
199
+ # ASCII-escaped JSON survives any console code page without a lossy "?" substitution.
200
+ _term.write(out, json.dumps(report.to_dict(), indent=2) + "\n")
201
+ else:
202
+ _term.write(out, render_report(report, color=_term.color_enabled(out, args.no_color)))
203
+ return 0
204
+
205
+
206
+ def _stdin_is_interactive() -> bool:
207
+ stream = sys.stdin
208
+ if stream is None:
209
+ return True
210
+ try:
211
+ return stream.isatty()
212
+ except (AttributeError, ValueError, OSError):
213
+ # Unknown means "do not block waiting for input that may never come".
214
+ return True
215
+
216
+
217
+ def _read_stdin() -> str:
218
+ stream = sys.stdin
219
+ if stream is None:
220
+ return ""
221
+ raw = getattr(stream, "buffer", None)
222
+ if raw is None:
223
+ text = stream.read(STDIN_LIMIT + 1)
224
+ truncated = len(text) > STDIN_LIMIT
225
+ text = text[:STDIN_LIMIT]
226
+ else:
227
+ # Bytes, not text: Windows decodes piped input with the ANSI code page, which garbles a
228
+ # UTF-8 message and fails outright on bytes cp1252 leaves undefined.
229
+ data = raw.read(STDIN_LIMIT + 1)
230
+ truncated = len(data) > STDIN_LIMIT
231
+ text = _decode(data[:STDIN_LIMIT])
232
+ if truncated:
233
+ _term.write(sys.stderr, "rithik: input is over 1 MiB; only the first 1 MiB was checked.\n")
234
+ return text
235
+
236
+
237
+ def _decode(data: bytes) -> str:
238
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
239
+ return data.decode("utf-16", errors="replace")
240
+ try:
241
+ return data.decode("utf-8-sig")
242
+ except UnicodeDecodeError:
243
+ pass
244
+ encoding = locale.getpreferredencoding(False) or "utf-8"
245
+ try:
246
+ return data.decode(encoding, errors="replace")
247
+ except LookupError:
248
+ return data.decode("utf-8", errors="replace")
249
+
250
+
251
+ def _defang(url: str) -> str:
252
+ """Write a link so that no terminal or chat app will turn it back into a clickable one."""
253
+ match = _SCHEME.match(url)
254
+ scheme = ""
255
+ rest = url
256
+ if match:
257
+ scheme = re.sub(r"(?i)^http", "hxxp", match.group(0))
258
+ rest = url[match.end() :]
259
+ host_end = len(rest)
260
+ for mark in "/?#":
261
+ index = rest.find(mark)
262
+ if index != -1:
263
+ host_end = min(host_end, index)
264
+ return scheme + rest[:host_end].replace(".", "[.]") + rest[host_end:]
265
+
266
+
267
+ def _defang_text(text: str, urls: Sequence[str]) -> str:
268
+ text = _LINK_IN_TEXT.sub(lambda m: _defang(m.group(0)), text)
269
+ # Links without a scheme ("sbi-kyc.top/login") only show up because the engine found them.
270
+ for url in sorted(urls, key=len, reverse=True):
271
+ text = text.replace(url, _defang(url))
272
+ return text
273
+
274
+
275
+ def _shorten(text: str) -> str:
276
+ text = " ".join(text.split())
277
+ if len(text) <= _EVIDENCE_LIMIT:
278
+ return text
279
+ return text[: _EVIDENCE_LIMIT - 3].rstrip() + "..."
280
+
281
+
282
+ def _bullet(text: str) -> list[str]:
283
+ return textwrap.wrap(
284
+ text,
285
+ WIDTH,
286
+ initial_indent=" - ",
287
+ subsequent_indent=" ",
288
+ break_on_hyphens=False,
289
+ ) or [" -"]
290
+
291
+
292
+ def _silence_stdout() -> None:
293
+ try:
294
+ devnull = os.open(os.devnull, os.O_WRONLY)
295
+ os.dup2(devnull, sys.stdout.fileno())
296
+ except (OSError, ValueError, AttributeError):
297
+ pass
@@ -0,0 +1,9 @@
1
+ """Offline heuristics for Indian scam messages and links.
2
+
3
+ The public surface is `analyze(text) -> Report`; everything else in this package is internal.
4
+ """
5
+
6
+ from rithik.scam.engine import analyze
7
+ from rithik.scam.model import Reason, Report, Verdict
8
+
9
+ __all__ = ["Reason", "Report", "Verdict", "analyze"]