inklight 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.
- inklight/__init__.py +36 -0
- inklight/__main__.py +4 -0
- inklight/_brand.py +16 -0
- inklight/agent/__init__.py +178 -0
- inklight/agent/analysis.py +469 -0
- inklight/agent/answer.py +544 -0
- inklight/agent/compose.py +150 -0
- inklight/agent/conditions.py +363 -0
- inklight/agent/context.py +86 -0
- inklight/agent/plan.py +98 -0
- inklight/agent/planner.py +339 -0
- inklight/agent/runner.py +206 -0
- inklight/agent/tools.py +178 -0
- inklight/brains/__init__.py +112 -0
- inklight/brains/hub.py +68 -0
- inklight/brains/onnx_models.py +248 -0
- inklight/brains/remote.py +49 -0
- inklight/chunking.py +61 -0
- inklight/cli.py +505 -0
- inklight/cli_render.py +142 -0
- inklight/config.py +173 -0
- inklight/errors.py +47 -0
- inklight/extract/__init__.py +21 -0
- inklight/extract/classify.py +91 -0
- inklight/extract/fields.py +525 -0
- inklight/extract/values.py +127 -0
- inklight/library.py +219 -0
- inklight/ocr/__init__.py +29 -0
- inklight/ocr/base.py +23 -0
- inklight/ocr/indra.py +194 -0
- inklight/ocr/plaintext.py +43 -0
- inklight/py.typed +0 -0
- inklight/retrieval.py +69 -0
- inklight/safety.py +178 -0
- inklight/schemas.py +205 -0
- inklight/server/__init__.py +5 -0
- inklight/server/app.py +286 -0
- inklight/server/render.py +57 -0
- inklight/server/static/app.css +147 -0
- inklight/server/static/app.js +332 -0
- inklight/server/static/index.html +127 -0
- inklight/server/static/logo.svg +16 -0
- inklight/store.py +402 -0
- inklight/watch.py +103 -0
- inklight-0.1.0.dist-info/METADATA +202 -0
- inklight-0.1.0.dist-info/RECORD +51 -0
- inklight-0.1.0.dist-info/WHEEL +4 -0
- inklight-0.1.0.dist-info/entry_points.txt +2 -0
- inklight-0.1.0.dist-info/licenses/LICENSE +202 -0
- inklight-0.1.0.dist-info/licenses/NOTICE +15 -0
- inklight-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +70 -0
inklight/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Inklight — a local-first, OCR-powered document agent.
|
|
2
|
+
|
|
3
|
+
See documents. Understand meaning. Take action.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ._brand import NAME, TAGLINE
|
|
9
|
+
from .agent import DocumentAgent, Policy
|
|
10
|
+
from .config import Settings, load_settings
|
|
11
|
+
from .errors import InklightError, InputRejected, ModelUnavailable, OCRError, PermissionDenied
|
|
12
|
+
from .library import Library
|
|
13
|
+
from .schemas import AgentResult, DocumentInfo, ExtractedValue, Record, Source
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"NAME",
|
|
19
|
+
"TAGLINE",
|
|
20
|
+
"AgentResult",
|
|
21
|
+
"DocumentAgent",
|
|
22
|
+
"DocumentInfo",
|
|
23
|
+
"ExtractedValue",
|
|
24
|
+
"InklightError",
|
|
25
|
+
"InputRejected",
|
|
26
|
+
"Library",
|
|
27
|
+
"ModelUnavailable",
|
|
28
|
+
"OCRError",
|
|
29
|
+
"PermissionDenied",
|
|
30
|
+
"Policy",
|
|
31
|
+
"Record",
|
|
32
|
+
"Settings",
|
|
33
|
+
"Source",
|
|
34
|
+
"__version__",
|
|
35
|
+
"load_settings",
|
|
36
|
+
]
|
inklight/__main__.py
ADDED
inklight/_brand.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Brand constants in one place, so the product can be renamed without a code hunt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
NAME = os.environ.get("INKLIGHT_BRAND_NAME", "Inklight")
|
|
8
|
+
CLI = "inklight"
|
|
9
|
+
TAGLINE = "See documents. Understand meaning. Take action."
|
|
10
|
+
HOME_ENV = "INKLIGHT_HOME"
|
|
11
|
+
CONFIG_ENV = "INKLIGHT_CONFIG"
|
|
12
|
+
|
|
13
|
+
# Palette shared by the CLI theme, web UI and docs.
|
|
14
|
+
GRAPHITE = "#0B0F14"
|
|
15
|
+
CYAN = "#22D3EE"
|
|
16
|
+
AMBER = "#F5B544"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""DocumentAgent — the public entry point.
|
|
2
|
+
|
|
3
|
+
from inklight import DocumentAgent
|
|
4
|
+
agent = DocumentAgent()
|
|
5
|
+
result = agent.ask("Extract the invoice number, date, and total", files=["invoice.pdf"])
|
|
6
|
+
print(result.answer, result.sources)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..config import Settings, load_settings
|
|
18
|
+
from ..errors import InputRejected
|
|
19
|
+
from ..library import IngestReport, Library, Progress
|
|
20
|
+
from ..schemas import AgentResult, Record, TaskState
|
|
21
|
+
from .context import Policy, Scratch, ToolContext
|
|
22
|
+
from .plan import Intent, Plan
|
|
23
|
+
from .planner import make_plan, steps_for
|
|
24
|
+
from .runner import Runner
|
|
25
|
+
from .tools import TOOLS, describe_tools
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DocumentAgent:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
settings: Settings | None = None,
|
|
32
|
+
*,
|
|
33
|
+
library: Library | None = None,
|
|
34
|
+
allow_write: bool = False,
|
|
35
|
+
output_dir: str | Path | None = None,
|
|
36
|
+
**overrides: Any,
|
|
37
|
+
) -> None:
|
|
38
|
+
if settings is None:
|
|
39
|
+
settings = load_settings(**overrides)
|
|
40
|
+
elif overrides:
|
|
41
|
+
settings = settings.with_overrides(**overrides)
|
|
42
|
+
self.settings = settings
|
|
43
|
+
self.library = library or Library(settings)
|
|
44
|
+
self.policy = Policy(allow_write=allow_write, output_dir=Path(output_dir).resolve() if output_dir else None)
|
|
45
|
+
lim = settings.limits
|
|
46
|
+
self.runner = Runner(lim.max_steps, lim.task_timeout_s, lim.step_timeout_s, lim.max_retries)
|
|
47
|
+
self._active: dict[str, threading.Event] = {}
|
|
48
|
+
|
|
49
|
+
# -- library passthroughs ---------------------------------------------
|
|
50
|
+
|
|
51
|
+
def add(
|
|
52
|
+
self, paths: Iterable[str | Path] | str | Path, progress: Progress | None = None, password: str | None = None
|
|
53
|
+
) -> IngestReport:
|
|
54
|
+
items = [paths] if isinstance(paths, str | Path) else list(paths)
|
|
55
|
+
return self.library.add(items, progress=progress, password=password)
|
|
56
|
+
|
|
57
|
+
# -- tasks -------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def plan(self, request: str) -> Plan:
|
|
60
|
+
return make_plan(request, self.library.brain)
|
|
61
|
+
|
|
62
|
+
def ask(
|
|
63
|
+
self,
|
|
64
|
+
request: str,
|
|
65
|
+
files: Iterable[str | Path] | None = None,
|
|
66
|
+
*,
|
|
67
|
+
doc_ids: list[str] | None = None,
|
|
68
|
+
progress: Progress | None = None,
|
|
69
|
+
policy: Policy | None = None,
|
|
70
|
+
cancel: threading.Event | None = None,
|
|
71
|
+
save: bool = True,
|
|
72
|
+
) -> AgentResult:
|
|
73
|
+
"""Run one request over ``files`` (ingested as needed) or the whole library."""
|
|
74
|
+
request = (request or "").strip()
|
|
75
|
+
if not request:
|
|
76
|
+
raise InputRejected("The request is empty")
|
|
77
|
+
if len(request) > 4000:
|
|
78
|
+
raise InputRejected("The request is longer than 4000 characters")
|
|
79
|
+
report: IngestReport | None = None
|
|
80
|
+
if files is not None:
|
|
81
|
+
report = self.add(list(files), progress=progress)
|
|
82
|
+
scope = [d.id for d in report.documents]
|
|
83
|
+
if not scope:
|
|
84
|
+
reasons = "; ".join(f"{Path(p).name}: {e}" for p, e in report.failed) or "no supported files"
|
|
85
|
+
raise InputRejected(f"No readable documents ({reasons})")
|
|
86
|
+
elif doc_ids is not None:
|
|
87
|
+
scope = list(doc_ids)
|
|
88
|
+
else:
|
|
89
|
+
docs = sorted(self.library.documents(), key=lambda d: d.name.lower())
|
|
90
|
+
scope = [d.id for d in docs if d.status.value == "ready"]
|
|
91
|
+
plan = self.plan(request)
|
|
92
|
+
return self.run_plan(plan, scope, report=report, policy=policy, cancel=cancel, save=save)
|
|
93
|
+
|
|
94
|
+
def run_plan(
|
|
95
|
+
self,
|
|
96
|
+
plan: Plan,
|
|
97
|
+
doc_ids: list[str],
|
|
98
|
+
*,
|
|
99
|
+
report: IngestReport | None = None,
|
|
100
|
+
policy: Policy | None = None,
|
|
101
|
+
cancel: threading.Event | None = None,
|
|
102
|
+
save: bool = True,
|
|
103
|
+
) -> AgentResult:
|
|
104
|
+
scratch = Scratch(doc_ids=list(doc_ids))
|
|
105
|
+
if report:
|
|
106
|
+
for path, error in report.failed:
|
|
107
|
+
scratch.warn(f"Skipped {Path(path).name}: {error}")
|
|
108
|
+
scratch.warnings.extend(report.warnings)
|
|
109
|
+
if not doc_ids:
|
|
110
|
+
scratch.warn("No documents in scope. Add some with `inklight process <folder>` or pass files=.")
|
|
111
|
+
ctx = ToolContext(
|
|
112
|
+
library=self.library,
|
|
113
|
+
plan=plan,
|
|
114
|
+
policy=policy or self.policy,
|
|
115
|
+
scratch=scratch,
|
|
116
|
+
cancel=cancel or threading.Event(),
|
|
117
|
+
)
|
|
118
|
+
if not plan.steps:
|
|
119
|
+
plan = plan.model_copy(update={"steps": steps_for(plan)})
|
|
120
|
+
result = self.runner.run(ctx, plan)
|
|
121
|
+
if save:
|
|
122
|
+
self.library.store.save_task(result)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
async def ask_async(self, request: str, files: Iterable[str | Path] | None = None, **kw: Any) -> AgentResult:
|
|
126
|
+
return await asyncio.to_thread(self.ask, request, files, **kw)
|
|
127
|
+
|
|
128
|
+
def ask_many(self, requests: list[str], files: Iterable[str | Path] | None = None) -> list[AgentResult]:
|
|
129
|
+
files_list = list(files) if files is not None else None
|
|
130
|
+
return [self.ask(r, files_list) for r in requests]
|
|
131
|
+
|
|
132
|
+
def extract(
|
|
133
|
+
self,
|
|
134
|
+
files: Iterable[str | Path] | str | Path,
|
|
135
|
+
fields: list[str] | None = None,
|
|
136
|
+
schema: str | Path | dict | None = None,
|
|
137
|
+
) -> list[Record]:
|
|
138
|
+
"""Typed extraction without natural language: returns one Record per document."""
|
|
139
|
+
from ..extract import load_schema, schema_from_dict
|
|
140
|
+
|
|
141
|
+
items = [files] if isinstance(files, str | Path) else list(files)
|
|
142
|
+
if schema is not None:
|
|
143
|
+
specs = schema_from_dict(schema) if isinstance(schema, dict) else load_schema(schema)
|
|
144
|
+
names = [s.name for s in specs]
|
|
145
|
+
else:
|
|
146
|
+
specs, names = None, list(fields or [])
|
|
147
|
+
report = self.add(items)
|
|
148
|
+
scope = [d.id for d in report.documents]
|
|
149
|
+
if not scope:
|
|
150
|
+
reasons = "; ".join(f"{Path(p).name}: {e}" for p, e in report.failed) or "no supported files"
|
|
151
|
+
raise InputRejected(f"No readable documents ({reasons})")
|
|
152
|
+
plan = (
|
|
153
|
+
make_plan("extract " + ", ".join(names), self.library.brain)
|
|
154
|
+
if names
|
|
155
|
+
else make_plan("extract fields", self.library.brain)
|
|
156
|
+
)
|
|
157
|
+
plan = plan.model_copy(update={"intent": Intent.EXTRACT, "output": None})
|
|
158
|
+
if specs is not None:
|
|
159
|
+
# Custom specs carry labels/types the planner cannot know; extract them directly.
|
|
160
|
+
from ..extract import extract_record
|
|
161
|
+
|
|
162
|
+
docs = [d for d in (self.library.get(i) for i in scope) if d is not None]
|
|
163
|
+
day_first = self.settings.extraction.day_first
|
|
164
|
+
return [extract_record(d, self.library.pages(d.id), specs, day_first=day_first) for d in docs]
|
|
165
|
+
result = self.run_plan(plan.model_copy(update={"steps": steps_for(plan)}), scope, save=False)
|
|
166
|
+
if result.state == TaskState.FAILED:
|
|
167
|
+
raise InputRejected(result.answer)
|
|
168
|
+
return list(result.records)
|
|
169
|
+
|
|
170
|
+
def search(self, query: str, k: int = 8) -> list[Any]:
|
|
171
|
+
return self.library.search(query, k=k)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def tools() -> list[dict[str, Any]]:
|
|
175
|
+
return describe_tools()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = ["DocumentAgent", "Policy", "Plan", "Intent", "TOOLS"]
|