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.
Files changed (51) hide show
  1. inklight/__init__.py +36 -0
  2. inklight/__main__.py +4 -0
  3. inklight/_brand.py +16 -0
  4. inklight/agent/__init__.py +178 -0
  5. inklight/agent/analysis.py +469 -0
  6. inklight/agent/answer.py +544 -0
  7. inklight/agent/compose.py +150 -0
  8. inklight/agent/conditions.py +363 -0
  9. inklight/agent/context.py +86 -0
  10. inklight/agent/plan.py +98 -0
  11. inklight/agent/planner.py +339 -0
  12. inklight/agent/runner.py +206 -0
  13. inklight/agent/tools.py +178 -0
  14. inklight/brains/__init__.py +112 -0
  15. inklight/brains/hub.py +68 -0
  16. inklight/brains/onnx_models.py +248 -0
  17. inklight/brains/remote.py +49 -0
  18. inklight/chunking.py +61 -0
  19. inklight/cli.py +505 -0
  20. inklight/cli_render.py +142 -0
  21. inklight/config.py +173 -0
  22. inklight/errors.py +47 -0
  23. inklight/extract/__init__.py +21 -0
  24. inklight/extract/classify.py +91 -0
  25. inklight/extract/fields.py +525 -0
  26. inklight/extract/values.py +127 -0
  27. inklight/library.py +219 -0
  28. inklight/ocr/__init__.py +29 -0
  29. inklight/ocr/base.py +23 -0
  30. inklight/ocr/indra.py +194 -0
  31. inklight/ocr/plaintext.py +43 -0
  32. inklight/py.typed +0 -0
  33. inklight/retrieval.py +69 -0
  34. inklight/safety.py +178 -0
  35. inklight/schemas.py +205 -0
  36. inklight/server/__init__.py +5 -0
  37. inklight/server/app.py +286 -0
  38. inklight/server/render.py +57 -0
  39. inklight/server/static/app.css +147 -0
  40. inklight/server/static/app.js +332 -0
  41. inklight/server/static/index.html +127 -0
  42. inklight/server/static/logo.svg +16 -0
  43. inklight/store.py +402 -0
  44. inklight/watch.py +103 -0
  45. inklight-0.1.0.dist-info/METADATA +202 -0
  46. inklight-0.1.0.dist-info/RECORD +51 -0
  47. inklight-0.1.0.dist-info/WHEEL +4 -0
  48. inklight-0.1.0.dist-info/entry_points.txt +2 -0
  49. inklight-0.1.0.dist-info/licenses/LICENSE +202 -0
  50. inklight-0.1.0.dist-info/licenses/NOTICE +15 -0
  51. 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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"]