flowglyph 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 (102) hide show
  1. flowglyph/__init__.py +70 -0
  2. flowglyph/__main__.py +5 -0
  3. flowglyph/_internal/__init__.py +1 -0
  4. flowglyph/_internal/archives.py +44 -0
  5. flowglyph/_internal/graph.py +45 -0
  6. flowglyph/_internal/hashing.py +16 -0
  7. flowglyph/_internal/markdown.py +209 -0
  8. flowglyph/_internal/paths.py +20 -0
  9. flowglyph/_internal/search.py +67 -0
  10. flowglyph/_internal/text.py +42 -0
  11. flowglyph/_internal/xml.py +47 -0
  12. flowglyph/adapters/__init__.py +123 -0
  13. flowglyph/adapters/bpmn/__init__.py +226 -0
  14. flowglyph/adapters/bpmn/graph.py +85 -0
  15. flowglyph/adapters/bpmn/model.py +277 -0
  16. flowglyph/adapters/bpmn/rules.py +78 -0
  17. flowglyph/adapters/n8n/__init__.py +410 -0
  18. flowglyph/adapters/n8n/nodes.py +104 -0
  19. flowglyph/adapters/openapi/__init__.py +305 -0
  20. flowglyph/adapters/openapi/refs.py +69 -0
  21. flowglyph/adapters/sap/__init__.py +367 -0
  22. flowglyph/adapters/sap/edmx.py +295 -0
  23. flowglyph/adapters/sap_cpi/__init__.py +219 -0
  24. flowglyph/adapters/sap_cpi/bundle.py +99 -0
  25. flowglyph/adapters/sap_cpi/discovery.py +40 -0
  26. flowglyph/adapters/sap_cpi/graph.py +105 -0
  27. flowglyph/adapters/sap_cpi/iflow.py +221 -0
  28. flowglyph/adapters/sap_cpi/linking.py +102 -0
  29. flowglyph/adapters/sap_cpi/rules.py +184 -0
  30. flowglyph/adapters/sbpa/__init__.py +375 -0
  31. flowglyph/adapters/sbpa/package.py +139 -0
  32. flowglyph/adapters/uipath/__init__.py +253 -0
  33. flowglyph/adapters/uipath/graph.py +255 -0
  34. flowglyph/adapters/uipath/parser.py +244 -0
  35. flowglyph/adapters/uipath/project.py +76 -0
  36. flowglyph/adapters/uipath/taxonomy.py +113 -0
  37. flowglyph/adapters/uipath/taxonomy.yaml +142 -0
  38. flowglyph/adapters/uipath/template.py +94 -0
  39. flowglyph/adapters/uipath/xaml.py +148 -0
  40. flowglyph/catalog/__init__.py +25 -0
  41. flowglyph/catalog/extraction.py +108 -0
  42. flowglyph/catalog/lock.py +163 -0
  43. flowglyph/catalog/model.py +205 -0
  44. flowglyph/catalog/overrides.py +54 -0
  45. flowglyph/catalog/versioning.py +151 -0
  46. flowglyph/cli/__init__.py +14 -0
  47. flowglyph/cli/_shared.py +109 -0
  48. flowglyph/cli/commands/__init__.py +1 -0
  49. flowglyph/cli/commands/catalog.py +130 -0
  50. flowglyph/cli/commands/compile.py +72 -0
  51. flowglyph/cli/commands/context.py +64 -0
  52. flowglyph/cli/commands/enrich.py +76 -0
  53. flowglyph/cli/commands/explore.py +172 -0
  54. flowglyph/cli/commands/gateway.py +143 -0
  55. flowglyph/cli/commands/plan.py +77 -0
  56. flowglyph/cli/commands/skill.py +24 -0
  57. flowglyph/cli/commands/wiki.py +76 -0
  58. flowglyph/cli/main.py +62 -0
  59. flowglyph/compilers/__init__.py +87 -0
  60. flowglyph/compilers/_layout.py +77 -0
  61. flowglyph/compilers/bpmn.py +257 -0
  62. flowglyph/compilers/xaml.py +300 -0
  63. flowglyph/context.py +495 -0
  64. flowglyph/enrichment.py +268 -0
  65. flowglyph/errors.py +29 -0
  66. flowglyph/gateway/__init__.py +31 -0
  67. flowglyph/gateway/audit.py +29 -0
  68. flowglyph/gateway/executors/__init__.py +62 -0
  69. flowglyph/gateway/executors/_http.py +133 -0
  70. flowglyph/gateway/executors/auth.py +144 -0
  71. flowglyph/gateway/executors/connections.py +203 -0
  72. flowglyph/gateway/executors/http.py +100 -0
  73. flowglyph/gateway/executors/odata.py +288 -0
  74. flowglyph/gateway/executors/orchestrator.py +156 -0
  75. flowglyph/gateway/ledger.py +186 -0
  76. flowglyph/gateway/policy.py +80 -0
  77. flowglyph/gateway/server.py +297 -0
  78. flowglyph/gateway/service.py +307 -0
  79. flowglyph/ir.py +312 -0
  80. flowglyph/planner/__init__.py +111 -0
  81. flowglyph/planner/backends.py +77 -0
  82. flowglyph/planner/claude.py +98 -0
  83. flowglyph/planner/claude_code.py +31 -0
  84. flowglyph/planner/command.py +258 -0
  85. flowglyph/planner/documents.py +191 -0
  86. flowglyph/planner/model.py +93 -0
  87. flowglyph/planner/openai_compatible.py +125 -0
  88. flowglyph/planner/prompt.py +38 -0
  89. flowglyph/planner/resolver.py +299 -0
  90. flowglyph/py.typed +0 -0
  91. flowglyph/report.py +139 -0
  92. flowglyph/skill/SKILL.md +72 -0
  93. flowglyph/skill/__init__.py +1 -0
  94. flowglyph/sources.py +129 -0
  95. flowglyph/views.py +56 -0
  96. flowglyph/wiki.py +443 -0
  97. flowglyph-0.1.0.dist-info/METADATA +401 -0
  98. flowglyph-0.1.0.dist-info/RECORD +102 -0
  99. flowglyph-0.1.0.dist-info/WHEEL +4 -0
  100. flowglyph-0.1.0.dist-info/entry_points.txt +11 -0
  101. flowglyph-0.1.0.dist-info/licenses/LICENSE +201 -0
  102. flowglyph-0.1.0.dist-info/licenses/NOTICE +29 -0
flowglyph/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """FlowGlyph: enterprise automations as a vendor-neutral IR for agents.
2
+
3
+ ```python
4
+ import flowglyph
5
+ from flowglyph.views import effects_code
6
+
7
+ catalog = flowglyph.extract("path/to/uipath-project")
8
+ for capability in catalog.search("read configuration from excel", limit=5):
9
+ print(capability.id, effects_code(capability.effects), capability.idempotency.mode)
10
+ catalog.save("out")
11
+ ```
12
+ """
13
+
14
+ from importlib.metadata import PackageNotFoundError, version
15
+
16
+ from flowglyph.adapters import Adapter, Diagnostic, Extraction, register_adapter
17
+ from flowglyph.catalog import Catalog, Lock, extract, load_catalog
18
+ from flowglyph.catalog.lock import Bump
19
+ from flowglyph.catalog.versioning import Change
20
+ from flowglyph.errors import FlowGlyphError
21
+ from flowglyph.ir import (
22
+ IR_VERSION,
23
+ Capability,
24
+ Edge,
25
+ Effects,
26
+ Idempotency,
27
+ Node,
28
+ Parameter,
29
+ Security,
30
+ Semantics,
31
+ Source,
32
+ Tri,
33
+ Trigger,
34
+ Workflow,
35
+ export_json_schemas,
36
+ )
37
+
38
+ try:
39
+ __version__ = version("flowglyph")
40
+ except PackageNotFoundError:
41
+ __version__ = "0.0.0"
42
+
43
+ __all__ = [
44
+ "IR_VERSION",
45
+ "Adapter",
46
+ "Bump",
47
+ "Capability",
48
+ "Catalog",
49
+ "Change",
50
+ "Diagnostic",
51
+ "Edge",
52
+ "Effects",
53
+ "Extraction",
54
+ "FlowGlyphError",
55
+ "Idempotency",
56
+ "Lock",
57
+ "Node",
58
+ "Parameter",
59
+ "Security",
60
+ "Semantics",
61
+ "Source",
62
+ "Tri",
63
+ "Trigger",
64
+ "Workflow",
65
+ "__version__",
66
+ "export_json_schemas",
67
+ "extract",
68
+ "load_catalog",
69
+ "register_adapter",
70
+ ]
flowglyph/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Runs the command line with `python -m flowglyph`."""
2
+
3
+ from flowglyph.cli import run
4
+
5
+ run()
@@ -0,0 +1 @@
1
+ """Helpers shared across FlowGlyph. Not public API: they may change in any release."""
@@ -0,0 +1,44 @@
1
+ """Reading zip archives from untrusted sources in memory, with limits against zip bombs."""
2
+
3
+ import io
4
+ import zipfile
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ from flowglyph.errors import AdapterError
9
+
10
+ MAX_ENTRY_BYTES = 20_000_000
11
+ MAX_TOTAL_BYTES = 200_000_000
12
+ MAX_COMPRESSION_RATIO = 200
13
+
14
+
15
+ def read_zip(
16
+ source: Path | bytes, name: str, wanted: Callable[[str], bool] = lambda _: True
17
+ ) -> dict[str, bytes]:
18
+ """The entries of a zip archive that `wanted` accepts, read without touching the disk.
19
+
20
+ Raises:
21
+ AdapterError: If the archive is corrupt, or an entry is too large or compresses too well
22
+ to be read safely.
23
+ """
24
+ try:
25
+ archive = zipfile.ZipFile(io.BytesIO(source) if isinstance(source, bytes) else source)
26
+ except (zipfile.BadZipFile, OSError) as error:
27
+ raise AdapterError(f"{name}: {error}") from error
28
+ files: dict[str, bytes] = {}
29
+ total = 0
30
+ with archive:
31
+ for info in archive.infolist():
32
+ if info.is_dir() or not wanted(info.filename):
33
+ continue
34
+ ratio = info.file_size / max(info.compress_size, 1)
35
+ total += info.file_size
36
+ if info.file_size > MAX_ENTRY_BYTES or ratio > MAX_COMPRESSION_RATIO:
37
+ raise AdapterError(f"{name}: {info.filename} is too large to read safely")
38
+ if total > MAX_TOTAL_BYTES:
39
+ raise AdapterError(f"{name}: the archive is too large to read safely")
40
+ try:
41
+ files[info.filename] = archive.read(info)
42
+ except (zipfile.BadZipFile, OSError, RuntimeError) as error:
43
+ raise AdapterError(f"{name}: {info.filename}: {error}") from error
44
+ return files
@@ -0,0 +1,45 @@
1
+ """Graph steps every adapter needs: following flows through folded nodes, and marking joins."""
2
+
3
+ from collections import Counter
4
+ from collections.abc import Collection, Iterable, Mapping, Sequence
5
+
6
+ from flowglyph.ir import Edge, Node
7
+
8
+ type Flow = tuple[str, str | None]
9
+ """Where a flow goes and under which condition, if any."""
10
+
11
+
12
+ def follow(outgoing: Mapping[str, Sequence[Flow]], kept: Collection[str]) -> list[Edge]:
13
+ """Edges between kept nodes, following flows through the nodes that were folded away.
14
+
15
+ A flow keeps the first condition met on its way, so two branches that meet again in a
16
+ folded node still reach the next kept node as two edges, each with its own condition.
17
+ """
18
+ edges: set[Edge] = set()
19
+ for start in kept:
20
+ seen: set[Flow] = set()
21
+ pending = list(outgoing.get(start, ()))
22
+ while pending:
23
+ target, condition = pending.pop()
24
+ if target in kept:
25
+ edges.add(Edge(source=start, target=target, condition=condition))
26
+ elif (target, condition) not in seen:
27
+ seen.add((target, condition))
28
+ pending.extend(
29
+ (next_target, condition or next_condition)
30
+ for next_target, next_condition in outgoing.get(target, ())
31
+ )
32
+ return sorted(edges, key=lambda edge: (edge.source, edge.target, edge.condition or ""))
33
+
34
+
35
+ def with_joins(
36
+ nodes: Iterable[Node], edges: Iterable[Edge], parallel: Collection[str] = ()
37
+ ) -> list[Node]:
38
+ """Nodes where several edges meet wait for any of them, unless they join parallel branches."""
39
+ incoming = Counter(edge.target for edge in edges)
40
+ return [
41
+ node.model_copy(update={"join": "any"})
42
+ if incoming[node.id] > 1 and node.id not in parallel
43
+ else node
44
+ for node in nodes
45
+ ]
@@ -0,0 +1,16 @@
1
+ """Content hashes for lock files, stable across runs and machines."""
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def digest(data: bytes) -> str:
9
+ """Short, stable content hash used in lock files."""
10
+ return "sha256:" + hashlib.sha256(data).hexdigest()[:32]
11
+
12
+
13
+ def fingerprint(value: Any) -> str:
14
+ """Hash of a JSON-serialisable value, independent of key order."""
15
+ canonical = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
16
+ return digest(canonical.encode())
@@ -0,0 +1,209 @@
1
+ """The L1 representation: Markdown cards for capabilities and the catalog index."""
2
+
3
+ import json
4
+ import re
5
+ from collections import defaultdict
6
+ from collections.abc import Mapping
7
+ from typing import TYPE_CHECKING
8
+
9
+ from flowglyph._internal.text import first_line
10
+ from flowglyph.ir import Capability, Parameter, Tri, Workflow
11
+ from flowglyph.views import effects_code, retry_advice
12
+
13
+ if TYPE_CHECKING:
14
+ from flowglyph.catalog import Catalog
15
+
16
+ _UNSAFE_FILENAME = re.compile(r"[^\w.-]+")
17
+ _EFFECTS_LEGEND = (
18
+ "Effects codes: R reads, W mutates state, D destructive, X external. "
19
+ "Upper case means yes, lower case means unknown."
20
+ )
21
+
22
+
23
+ def card_filename(capability: Capability) -> str:
24
+ """File name of the Markdown card of `capability`."""
25
+ return _UNSAFE_FILENAME.sub("_", capability.id).strip("_") + ".md"
26
+
27
+
28
+ def _cell(value: object) -> str:
29
+ return str(value).replace("|", "\\|").replace("\n", " ")
30
+
31
+
32
+ def _effects_line(capability: Capability) -> str:
33
+ effects = capability.effects
34
+ parts = [
35
+ f"{name}: {value.value}"
36
+ for name, value in (
37
+ ("reads", effects.reads),
38
+ ("mutates state", effects.mutates_state),
39
+ ("destructive", effects.destructive),
40
+ ("external", effects.external),
41
+ )
42
+ ]
43
+ return ", ".join(parts)
44
+
45
+
46
+ def _trigger_line(capability: Capability) -> str:
47
+ trigger = capability.trigger
48
+ if trigger is None:
49
+ return ""
50
+ kind = {"call": "a call", "schedule": "a schedule", "event": "an event"}[trigger.kind]
51
+ via = f" through {trigger.channel}" if trigger.channel else ""
52
+ at = f" at `{trigger.address}`" if trigger.address else ""
53
+ return f"{kind}{via}{at}"
54
+
55
+
56
+ def _idempotency_line(capability: Capability) -> str:
57
+ idempotency = capability.idempotency
58
+ return (
59
+ f"{idempotency.mode}, {retry_advice(capability.idempotency)} "
60
+ f"({idempotency.provenance}, confidence {idempotency.confidence:.1f})"
61
+ )
62
+
63
+
64
+ def _parameter_table(parameters: list[Parameter], *, inputs: bool) -> list[str]:
65
+ if not parameters:
66
+ return ["None."]
67
+ header = (
68
+ "| Name | Type | Required | Description |" if inputs else "| Name | Type | Description |"
69
+ )
70
+ rows = [header, "|---|---|---|---|" if inputs else "|---|---|---|"]
71
+ for parameter in parameters:
72
+ description = _cell(parameter.description or "")
73
+ if inputs:
74
+ required = "yes" if parameter.required else "no"
75
+ rows.append(f"| `{parameter.name}` | {_type(parameter)} | {required} | {description} |")
76
+ else:
77
+ rows.append(f"| `{parameter.name}` | {_type(parameter)} | {description} |")
78
+ return rows
79
+
80
+
81
+ def _graph_section(workflow: Workflow, capabilities: Mapping[str, Capability]) -> list[str]:
82
+ lines = ["## Steps", ""]
83
+ for position, node in enumerate(workflow.nodes, start=1):
84
+ target = capabilities.get(node.capability) if node.capability else None
85
+ reference = f" -> [{target.title}]({card_filename(target)})" if target else ""
86
+ join = ", runs if any predecessor ran" if node.join == "any" else ""
87
+ lines.append(f"{position}. **{_cell(node.label)}** ({node.kind}{join}){reference}")
88
+ if node.note:
89
+ lines.append(f" {_cell(first_line(node.note, 240))}")
90
+ conditional = [edge for edge in workflow.edges if edge.condition]
91
+ if conditional:
92
+ lines += ["", "Branches:", ""]
93
+ lines += [f"- `{e.source}` -> `{e.target}` when `{e.condition}`" for e in conditional]
94
+ if workflow.unresolved_calls:
95
+ lines += ["", "Unresolved invocations:", ""]
96
+ lines += [f"- `{call}`" for call in workflow.unresolved_calls]
97
+ return [*lines, ""]
98
+
99
+
100
+ def render_capability(
101
+ capability: Capability,
102
+ workflows: Mapping[str, Workflow],
103
+ capabilities: Mapping[str, Capability],
104
+ ) -> str:
105
+ """The L1 card of a capability: what an agent reads before filling its arguments."""
106
+ front_matter = {
107
+ "id": capability.id,
108
+ "kind": capability.kind,
109
+ "category": capability.category,
110
+ "version": capability.version,
111
+ "effects": effects_code(capability.effects) or "none",
112
+ "idempotency": capability.idempotency.mode,
113
+ "invocable": capability.invocable,
114
+ }
115
+ lines = ["---", *(f"{key}: {json.dumps(value)}" for key, value in front_matter.items()), "---"]
116
+ lines += ["", f"# {capability.title}", ""]
117
+ if capability.description:
118
+ lines += [capability.description, ""]
119
+ if capability.generated:
120
+ written = " and ".join(capability.generated)
121
+ lines += [f"_The {written} were written by a language model: the source did not say._", ""]
122
+ lines += [
123
+ f"- **Source:** `{capability.source.location}` ({capability.source.provider})",
124
+ f"- **Effects:** {_effects_line(capability)}",
125
+ f"- **Idempotency:** {_idempotency_line(capability)}",
126
+ ]
127
+ if capability.trigger is not None:
128
+ lines.append(f"- **Started by:** {_trigger_line(capability)}")
129
+ if capability.configuration:
130
+ names = ", ".join(f"`{name}`" for name in capability.configuration)
131
+ lines.append(f"- **Configuration:** {names}")
132
+ if capability.deprecated:
133
+ lines.append("- **Deprecated** in its source")
134
+ if capability.security.approval_required:
135
+ lines.append("- **Approval:** required before every run")
136
+ lines.append("")
137
+ if capability.notes:
138
+ lines += ["> **Notes**", *(f"> - {note}" for note in capability.notes), ""]
139
+ lines += ["## Inputs", "", *_parameter_table(capability.inputs, inputs=True), ""]
140
+ if capability.kind != "activity":
141
+ lines += ["## Outputs", "", *_parameter_table(capability.outputs, inputs=False), ""]
142
+ workflow = workflows.get(capability.id)
143
+ if workflow is not None:
144
+ lines += _graph_section(workflow, capabilities)
145
+ if capability.used_in:
146
+ lines += ["## Used in", "", *(f"- `{location}`" for location in capability.used_in), ""]
147
+ lines += [
148
+ "## Contract",
149
+ "",
150
+ "```json",
151
+ json.dumps(
152
+ {"input": capability.input_schema, "output": capability.output_schema},
153
+ indent=2,
154
+ ensure_ascii=False,
155
+ ),
156
+ "```",
157
+ "",
158
+ ]
159
+ return "\n".join(lines)
160
+
161
+
162
+ def render_index(catalog: "Catalog") -> str:
163
+ """The catalog's front page: every workflow and activity at a glance."""
164
+ workflows = sorted(
165
+ (c for c in catalog if c.kind != "activity"), key=lambda capability: capability.id
166
+ )
167
+ activities = sorted((c for c in catalog if c.kind == "activity"), key=lambda c: c.title)
168
+ lines = [
169
+ "# Catalog",
170
+ "",
171
+ f"{len(workflows)} invocable capabilities, {len(activities)} activity types.",
172
+ "",
173
+ "## Invocable",
174
+ "",
175
+ "| Capability | Description | Inputs | Effects | Idempotency | Version |",
176
+ "|---|---|---|---|---|---|",
177
+ ]
178
+ for capability in workflows:
179
+ inputs = ", ".join(f"`{parameter.name}`" for parameter in capability.inputs) or "none"
180
+ lines.append(
181
+ f"| [{capability.title}](capabilities/{card_filename(capability)}) "
182
+ f"| {_cell(first_line(capability.description, 100))} | {inputs} "
183
+ f"| {effects_code(capability.effects) or 'none'} | {capability.idempotency.mode} "
184
+ f"| {capability.version} |"
185
+ )
186
+ by_category: defaultdict[str, list[Capability]] = defaultdict(list)
187
+ for capability in activities:
188
+ by_category[capability.category].append(capability)
189
+ if by_category:
190
+ lines += ["", "## Activity types", ""]
191
+ for category in sorted(by_category):
192
+ lines += [f"### {category}", ""]
193
+ for capability in by_category[category]:
194
+ flag = " (side effects)" if capability.effects.has_side_effects else ""
195
+ mutates = capability.effects.mutates_state
196
+ certainty = " (unknown)" if mutates is Tri.UNKNOWN else ""
197
+ lines.append(
198
+ f"- [{capability.title}](capabilities/{card_filename(capability)}){flag}{certainty}"
199
+ )
200
+ lines.append("")
201
+ lines += [
202
+ _EFFECTS_LEGEND,
203
+ "",
204
+ ]
205
+ return "\n".join(lines)
206
+
207
+
208
+ def _type(parameter: Parameter) -> str:
209
+ return f"{parameter.type} or null" if parameter.nullable else parameter.type
@@ -0,0 +1,20 @@
1
+ """Short names that tell apart artifacts sharing a name in different folders."""
2
+
3
+ import re
4
+ from pathlib import PurePosixPath
5
+
6
+ _UNSAFE = re.compile(r"[^A-Za-z0-9.-]+")
7
+
8
+
9
+ def distinguishing_names(paths: list[str]) -> list[str]:
10
+ """The shortest path segment that tells each of several files apart.
11
+
12
+ `["v2/API/API.edmx", "v4/API/API.edmx"]` gives `["v2", "v4"]`; files in one folder fall back
13
+ to their names.
14
+ """
15
+ parts = [PurePosixPath(path).with_suffix("").parts for path in paths]
16
+ for index in range(max(len(p) for p in parts)):
17
+ segments = [p[index] if index < len(p) else "" for p in parts]
18
+ if len(set(segments)) == len(segments):
19
+ return [_UNSAFE.sub("_", segment).strip("_") for segment in segments]
20
+ return [_UNSAFE.sub("_", "/".join(p)).strip("_") for p in parts]
@@ -0,0 +1,67 @@
1
+ """BM25 ranking over capabilities, with weighted fields and no dependencies."""
2
+
3
+ import math
4
+ from collections import Counter
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+
8
+ from flowglyph._internal.text import tokenize
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Document:
13
+ """A searchable text with its fields weighted by repetition."""
14
+
15
+ key: str
16
+ fields: Sequence[tuple[str, int]]
17
+
18
+
19
+ @dataclass
20
+ class BM25Index:
21
+ """Okapi BM25 over a fixed set of documents.
22
+
23
+ Small enough to avoid a dependency and fast enough for catalogs of tens of thousands of
24
+ capabilities.
25
+ """
26
+
27
+ documents: Sequence[Document]
28
+ k1: float = 1.5
29
+ b: float = 0.75
30
+ _frequencies: list[Counter[str]] = field(init=False, default_factory=list[Counter[str]])
31
+ _idf: dict[str, float] = field(init=False, default_factory=dict[str, float])
32
+ _average_length: float = field(init=False, default=0.0)
33
+
34
+ def __post_init__(self) -> None:
35
+ for document in self.documents:
36
+ counts: Counter[str] = Counter()
37
+ for text, weight in document.fields:
38
+ for token in tokenize(text):
39
+ counts[token] += weight
40
+ self._frequencies.append(counts)
41
+ total = len(self._frequencies) or 1
42
+ self._average_length = sum(sum(c.values()) for c in self._frequencies) / total
43
+ document_frequency: Counter[str] = Counter()
44
+ for counts in self._frequencies:
45
+ document_frequency.update(counts.keys())
46
+ self._idf = {
47
+ token: math.log(1 + (total - seen + 0.5) / (seen + 0.5))
48
+ for token, seen in document_frequency.items()
49
+ }
50
+
51
+ def search(self, query: str, limit: int = 10) -> list[tuple[str, float]]:
52
+ """Keys of the best matching documents with their scores, best first."""
53
+ terms = tokenize(query)
54
+ scored: list[tuple[str, float]] = []
55
+ for document, counts in zip(self.documents, self._frequencies, strict=True):
56
+ length = sum(counts.values())
57
+ score = 0.0
58
+ for term in terms:
59
+ frequency = counts.get(term, 0)
60
+ if not frequency:
61
+ continue
62
+ normaliser = self.k1 * (1 - self.b + self.b * length / (self._average_length or 1))
63
+ score += self._idf[term] * frequency * (self.k1 + 1) / (frequency + normaliser)
64
+ if score > 0:
65
+ scored.append((document.key, score))
66
+ scored.sort(key=lambda item: (-item[1], item[0]))
67
+ return scored[:limit]
@@ -0,0 +1,42 @@
1
+ """Tokenizing and trimming text for search and summaries."""
2
+
3
+ import re
4
+
5
+ _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
6
+ _WORD = re.compile(r"[^\W_]+")
7
+ _ARGUMENT_PREFIX = re.compile(r"^(in|out|io)_")
8
+ _SHORTEST_PLURAL = 4
9
+
10
+
11
+ def tokenize(text: str) -> list[str]:
12
+ """Lower-case search tokens, splitting camelCase and snake_case identifiers.
13
+
14
+ UiPath argument prefixes (`in_`, `out_`, `io_`) are dropped so that `in_ConfigFile`
15
+ matches a search for "config file".
16
+ """
17
+ tokens: list[str] = []
18
+ for raw in text.split():
19
+ for word in _WORD.findall(_ARGUMENT_PREFIX.sub("", raw)):
20
+ tokens.extend(
21
+ _singular(part.lower()) for part in _CAMEL_BOUNDARY.split(word) if len(part) > 1
22
+ )
23
+ return tokens
24
+
25
+
26
+ def _singular(token: str) -> str:
27
+ """Fold simple English plurals so "duplicates" matches "Duplicate"."""
28
+ if len(token) > _SHORTEST_PLURAL and token.endswith("ies"):
29
+ return token[:-3] + "y"
30
+ if (
31
+ len(token) >= _SHORTEST_PLURAL
32
+ and token.endswith("s")
33
+ and not token.endswith(("ss", "us", "is"))
34
+ ):
35
+ return token[:-1]
36
+ return token
37
+
38
+
39
+ def first_line(text: str, limit: int = 120) -> str:
40
+ """The first line of `text`, cut to `limit` characters."""
41
+ line = text.strip().split("\n", 1)[0].strip()
42
+ return line if len(line) <= limit else line[: limit - 1].rstrip() + "…"
@@ -0,0 +1,47 @@
1
+ """XML parsing for untrusted sources: documents that declare a DTD are refused."""
2
+
3
+ from pathlib import Path
4
+ from xml.etree import ElementTree as ET
5
+ from xml.parsers import expat
6
+
7
+ from flowglyph.errors import AdapterError
8
+
9
+ _LAST_CHUNK = True
10
+
11
+
12
+ def load_xml(path: Path) -> ET.Element:
13
+ """Parse an XML file, refusing documents that declare a DTD.
14
+
15
+ Raises:
16
+ AdapterError: If the file is not well-formed XML or declares a DTD.
17
+ """
18
+ return parse_xml(path.read_bytes(), str(path))
19
+
20
+
21
+ def parse_xml(data: bytes, name: str) -> ET.Element:
22
+ """Parse XML bytes, refusing documents that declare a DTD.
23
+
24
+ Raises:
25
+ AdapterError: If the data is not well-formed XML or declares a DTD.
26
+ """
27
+ _refuse_dtd(data, name)
28
+ try:
29
+ # ElementTree never resolves external entities, and with DTDs refused there are no
30
+ # internal entities to expand, so this input is safe to parse.
31
+ return ET.fromstring(data) # noqa: S314
32
+ except ET.ParseError as error:
33
+ raise AdapterError(f"{name}: {error}") from error
34
+
35
+
36
+ def _refuse_dtd(data: bytes, name: str) -> None:
37
+ """Scan the whole document with expat, which reports a DOCTYPE wherever it appears."""
38
+
39
+ def refuse(*_: object) -> None:
40
+ raise AdapterError(f"{name}: XML documents must not declare a DTD")
41
+
42
+ scanner = expat.ParserCreate()
43
+ scanner.StartDoctypeDeclHandler = refuse
44
+ try:
45
+ scanner.Parse(data, _LAST_CHUNK)
46
+ except expat.ExpatError as error:
47
+ raise AdapterError(f"{name}: {error}") from error