htmlgraph 0.27.2__py3-none-any.whl → 0.27.3__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.
@@ -0,0 +1,509 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Graph Query Composer for HtmlGraph.
5
+
6
+ Extends QueryBuilder capabilities with graph traversal, enabling queries
7
+ that combine attribute filtering WITH edge traversal.
8
+
9
+ Example:
10
+ from htmlgraph import HtmlGraph
11
+
12
+ graph = HtmlGraph("features/")
13
+
14
+ # Find blocked features whose blockers are high-priority
15
+ results = graph.query_composer() \\
16
+ .where("status", "blocked") \\
17
+ .traverse("blocked_by", direction="outgoing") \\
18
+ .where("priority", "high") \\
19
+ .execute()
20
+
21
+ # Find all features transitively reachable via depends_on from a root
22
+ results = graph.query_composer() \\
23
+ .reachable_from("feat-001", "depends_on") \\
24
+ .where("status", "todo") \\
25
+ .execute()
26
+ """
27
+
28
+ from collections import deque
29
+ from dataclasses import dataclass, field
30
+ from typing import TYPE_CHECKING, Any, Literal
31
+
32
+ from htmlgraph.query_builder import Condition, LogicalOp, Operator
33
+
34
+ if TYPE_CHECKING:
35
+ from htmlgraph.graph import HtmlGraph
36
+ from htmlgraph.models import Node
37
+
38
+
39
+ # Sentinel object to distinguish "no value passed" from None
40
+ _SENTINEL: Any = object()
41
+
42
+
43
+ @dataclass
44
+ class QueryStage:
45
+ """A single stage in the query execution pipeline."""
46
+
47
+ stage_type: Literal["filter", "traverse", "traverse_recursive", "reachable_from"]
48
+ params: dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ class GraphQueryComposer:
52
+ """
53
+ Composes graph traversal with attribute filtering.
54
+
55
+ Wraps QueryBuilder condition logic and EdgeIndex traversal into
56
+ a pipeline of stages. Each stage narrows the working set of nodes.
57
+
58
+ Example:
59
+ composer = GraphQueryComposer(graph)
60
+ results = composer \\
61
+ .where("status", "blocked") \\
62
+ .traverse("blocked_by") \\
63
+ .where("priority", "high") \\
64
+ .execute()
65
+ """
66
+
67
+ def __init__(self, graph: HtmlGraph) -> None:
68
+ self.graph = graph
69
+ self._stages: list[QueryStage] = []
70
+ # Accumulate filter conditions for the current filter group.
71
+ # When a traverse stage is added, pending conditions are flushed
72
+ # into a filter stage.
73
+ self._pending_conditions: list[Condition] = []
74
+
75
+ # ------------------------------------------------------------------
76
+ # Attribute filtering (delegates to QueryBuilder condition logic)
77
+ # ------------------------------------------------------------------
78
+
79
+ def where(self, attr: str, value: Any = _SENTINEL) -> GraphQueryComposer:
80
+ """Start or add an attribute filter on the current node set.
81
+
82
+ Args:
83
+ attr: Attribute name (supports dot notation for nested access).
84
+ value: If provided, shorthand for equality check.
85
+
86
+ Returns:
87
+ Self for chaining.
88
+ """
89
+ if value is not _SENTINEL:
90
+ self._pending_conditions.append(
91
+ Condition(
92
+ attribute=attr,
93
+ operator=Operator.EQ,
94
+ value=value,
95
+ logical_op=LogicalOp.AND,
96
+ )
97
+ )
98
+ else:
99
+ # When no value is given, add a placeholder condition that must
100
+ # be completed by a subsequent operator call. For simplicity
101
+ # we store an IS_NOT_NULL condition which acts as a "has attr"
102
+ # check; callers who want other operators should use and_/or_.
103
+ self._pending_conditions.append(
104
+ Condition(
105
+ attribute=attr,
106
+ operator=Operator.IS_NOT_NULL,
107
+ value=None,
108
+ logical_op=LogicalOp.AND,
109
+ )
110
+ )
111
+ return self
112
+
113
+ def and_(self, attr: str, value: Any = _SENTINEL) -> GraphQueryComposer:
114
+ """Add an AND condition.
115
+
116
+ Args:
117
+ attr: Attribute name.
118
+ value: If provided, shorthand for equality check.
119
+
120
+ Returns:
121
+ Self for chaining.
122
+ """
123
+ if value is not _SENTINEL:
124
+ self._pending_conditions.append(
125
+ Condition(
126
+ attribute=attr,
127
+ operator=Operator.EQ,
128
+ value=value,
129
+ logical_op=LogicalOp.AND,
130
+ )
131
+ )
132
+ else:
133
+ self._pending_conditions.append(
134
+ Condition(
135
+ attribute=attr,
136
+ operator=Operator.IS_NOT_NULL,
137
+ value=None,
138
+ logical_op=LogicalOp.AND,
139
+ )
140
+ )
141
+ return self
142
+
143
+ def or_(self, attr: str, value: Any = _SENTINEL) -> GraphQueryComposer:
144
+ """Add an OR condition.
145
+
146
+ Args:
147
+ attr: Attribute name.
148
+ value: If provided, shorthand for equality check.
149
+
150
+ Returns:
151
+ Self for chaining.
152
+ """
153
+ if value is not _SENTINEL:
154
+ self._pending_conditions.append(
155
+ Condition(
156
+ attribute=attr,
157
+ operator=Operator.EQ,
158
+ value=value,
159
+ logical_op=LogicalOp.OR,
160
+ )
161
+ )
162
+ else:
163
+ self._pending_conditions.append(
164
+ Condition(
165
+ attribute=attr,
166
+ operator=Operator.IS_NOT_NULL,
167
+ value=None,
168
+ logical_op=LogicalOp.OR,
169
+ )
170
+ )
171
+ return self
172
+
173
+ # ------------------------------------------------------------------
174
+ # Relationship traversal
175
+ # ------------------------------------------------------------------
176
+
177
+ def traverse(
178
+ self, relationship: str, direction: str = "outgoing"
179
+ ) -> GraphQueryComposer:
180
+ """Follow edges of given relationship type from current result set.
181
+
182
+ For each node in the working set, collect the nodes reachable
183
+ by one hop via *relationship* in the specified *direction*.
184
+
185
+ Args:
186
+ relationship: Edge relationship type (e.g. ``"blocked_by"``).
187
+ direction: ``"outgoing"``, ``"incoming"``, or ``"both"``.
188
+
189
+ Returns:
190
+ Self for chaining.
191
+ """
192
+ self._flush_conditions()
193
+ self._stages.append(
194
+ QueryStage(
195
+ stage_type="traverse",
196
+ params={"relationship": relationship, "direction": direction},
197
+ )
198
+ )
199
+ return self
200
+
201
+ def traverse_recursive(
202
+ self,
203
+ relationship: str,
204
+ direction: str = "outgoing",
205
+ max_depth: int = 10,
206
+ ) -> GraphQueryComposer:
207
+ """Follow edges recursively (transitive closure) up to *max_depth*.
208
+
209
+ Args:
210
+ relationship: Edge relationship type.
211
+ direction: ``"outgoing"``, ``"incoming"``, or ``"both"``.
212
+ max_depth: Maximum traversal depth.
213
+
214
+ Returns:
215
+ Self for chaining.
216
+ """
217
+ self._flush_conditions()
218
+ self._stages.append(
219
+ QueryStage(
220
+ stage_type="traverse_recursive",
221
+ params={
222
+ "relationship": relationship,
223
+ "direction": direction,
224
+ "max_depth": max_depth,
225
+ },
226
+ )
227
+ )
228
+ return self
229
+
230
+ def reachable_from(
231
+ self,
232
+ node_id: str,
233
+ relationship: str,
234
+ direction: str = "outgoing",
235
+ max_depth: int = 10,
236
+ ) -> GraphQueryComposer:
237
+ """Filter to nodes reachable from *node_id* via *relationship*.
238
+
239
+ This resets the working set to all nodes transitively reachable
240
+ from the given starting node (excluding the starting node itself).
241
+
242
+ Args:
243
+ node_id: Starting node ID.
244
+ relationship: Edge relationship type.
245
+ direction: ``"outgoing"``, ``"incoming"``, or ``"both"``.
246
+ max_depth: Maximum traversal depth.
247
+
248
+ Returns:
249
+ Self for chaining.
250
+ """
251
+ self._flush_conditions()
252
+ self._stages.append(
253
+ QueryStage(
254
+ stage_type="reachable_from",
255
+ params={
256
+ "node_id": node_id,
257
+ "relationship": relationship,
258
+ "direction": direction,
259
+ "max_depth": max_depth,
260
+ },
261
+ )
262
+ )
263
+ return self
264
+
265
+ # ------------------------------------------------------------------
266
+ # Convenience methods
267
+ # ------------------------------------------------------------------
268
+
269
+ def blocked_by_chain(self, feature_id: str) -> GraphQueryComposer:
270
+ """Find all features in the ``blocked_by`` chain from *feature_id*.
271
+
272
+ Equivalent to ``reachable_from(feature_id, "blocked_by", "outgoing")``.
273
+
274
+ Args:
275
+ feature_id: Starting feature node ID.
276
+
277
+ Returns:
278
+ Self for chaining.
279
+ """
280
+ return self.reachable_from(feature_id, "blocked_by", direction="outgoing")
281
+
282
+ def dependency_chain(self, feature_id: str) -> GraphQueryComposer:
283
+ """Find all features in the ``depends_on`` chain from *feature_id*.
284
+
285
+ Equivalent to ``reachable_from(feature_id, "depends_on", "outgoing")``.
286
+
287
+ Args:
288
+ feature_id: Starting feature node ID.
289
+
290
+ Returns:
291
+ Self for chaining.
292
+ """
293
+ return self.reachable_from(feature_id, "depends_on", direction="outgoing")
294
+
295
+ # ------------------------------------------------------------------
296
+ # Execution
297
+ # ------------------------------------------------------------------
298
+
299
+ def execute(self) -> list[Node]:
300
+ """Execute all stages and return matching nodes.
301
+
302
+ Returns:
303
+ List of nodes matching the composed query.
304
+ """
305
+ # Flush any remaining pending conditions
306
+ self._flush_conditions()
307
+
308
+ self.graph._ensure_loaded() # noqa: SLF001
309
+
310
+ # Start with all node IDs
311
+ working_set: set[str] = set(self.graph._nodes.keys()) # noqa: SLF001
312
+
313
+ for stage in self._stages:
314
+ if stage.stage_type == "filter":
315
+ working_set = self._apply_filter(working_set, stage)
316
+ elif stage.stage_type == "traverse":
317
+ working_set = self._apply_traverse(working_set, stage)
318
+ elif stage.stage_type == "traverse_recursive":
319
+ working_set = self._apply_traverse_recursive(working_set, stage)
320
+ elif stage.stage_type == "reachable_from":
321
+ working_set = self._apply_reachable_from(working_set, stage)
322
+
323
+ # Resolve IDs to Node objects, preserving only nodes that still exist
324
+ nodes = self.graph._nodes # noqa: SLF001
325
+ return [nodes[nid] for nid in working_set if nid in nodes]
326
+
327
+ def count(self) -> int:
328
+ """Execute and return count of matches.
329
+
330
+ Returns:
331
+ Number of matching nodes.
332
+ """
333
+ return len(self.execute())
334
+
335
+ def first(self) -> Node | None:
336
+ """Execute and return first match or ``None``.
337
+
338
+ Returns:
339
+ First matching node or ``None``.
340
+ """
341
+ results = self.execute()
342
+ return results[0] if results else None
343
+
344
+ def ids(self) -> list[str]:
345
+ """Execute and return just node IDs.
346
+
347
+ Returns:
348
+ List of matching node IDs.
349
+ """
350
+ return [node.id for node in self.execute()]
351
+
352
+ # ------------------------------------------------------------------
353
+ # Internal helpers
354
+ # ------------------------------------------------------------------
355
+
356
+ def _flush_conditions(self) -> None:
357
+ """Flush accumulated conditions into a filter stage."""
358
+ if self._pending_conditions:
359
+ self._stages.append(
360
+ QueryStage(
361
+ stage_type="filter",
362
+ params={"conditions": list(self._pending_conditions)},
363
+ )
364
+ )
365
+ self._pending_conditions = []
366
+
367
+ def _apply_filter(self, working_set: set[str], stage: QueryStage) -> set[str]:
368
+ """Apply attribute filter conditions to the working set."""
369
+ conditions: list[Condition] = stage.params["conditions"]
370
+ nodes = self.graph._nodes # noqa: SLF001
371
+ result: set[str] = set()
372
+
373
+ for nid in working_set:
374
+ node = nodes.get(nid)
375
+ if node is None:
376
+ continue
377
+ if self._evaluate_conditions(node, conditions):
378
+ result.add(nid)
379
+
380
+ return result
381
+
382
+ def _apply_traverse(self, working_set: set[str], stage: QueryStage) -> set[str]:
383
+ """Follow one hop of edges from working set."""
384
+ relationship: str = stage.params["relationship"]
385
+ direction: str = stage.params["direction"]
386
+ edge_index = self.graph._edge_index # noqa: SLF001
387
+ result: set[str] = set()
388
+
389
+ for nid in working_set:
390
+ if direction in ("outgoing", "both"):
391
+ for ref in edge_index.get_outgoing(nid, relationship):
392
+ result.add(ref.target_id)
393
+ if direction in ("incoming", "both"):
394
+ for ref in edge_index.get_incoming(nid, relationship):
395
+ result.add(ref.source_id)
396
+
397
+ return result
398
+
399
+ def _apply_traverse_recursive(
400
+ self, working_set: set[str], stage: QueryStage
401
+ ) -> set[str]:
402
+ """Follow edges recursively from working set with depth limit."""
403
+ relationship: str = stage.params["relationship"]
404
+ direction: str = stage.params["direction"]
405
+ max_depth: int = stage.params["max_depth"]
406
+ edge_index = self.graph._edge_index # noqa: SLF001
407
+
408
+ result: set[str] = set()
409
+ # BFS from every node in working set
410
+ queue: deque[tuple[str, int]] = deque()
411
+ for nid in working_set:
412
+ queue.append((nid, 0))
413
+
414
+ visited: set[str] = set(working_set)
415
+
416
+ while queue:
417
+ current, depth = queue.popleft()
418
+ if depth >= max_depth:
419
+ continue
420
+
421
+ neighbors: set[str] = set()
422
+ if direction in ("outgoing", "both"):
423
+ for ref in edge_index.get_outgoing(current, relationship):
424
+ neighbors.add(ref.target_id)
425
+ if direction in ("incoming", "both"):
426
+ for ref in edge_index.get_incoming(current, relationship):
427
+ neighbors.add(ref.source_id)
428
+
429
+ for neighbor in neighbors:
430
+ if neighbor not in visited:
431
+ visited.add(neighbor)
432
+ result.add(neighbor)
433
+ queue.append((neighbor, depth + 1))
434
+
435
+ return result
436
+
437
+ def _apply_reachable_from(
438
+ self, working_set: set[str], stage: QueryStage
439
+ ) -> set[str]:
440
+ """Compute nodes reachable from a specific node, intersected with working set."""
441
+ node_id: str = stage.params["node_id"]
442
+ relationship: str = stage.params["relationship"]
443
+ direction: str = stage.params["direction"]
444
+ max_depth: int = stage.params["max_depth"]
445
+ edge_index = self.graph._edge_index # noqa: SLF001
446
+
447
+ reachable: set[str] = set()
448
+ visited: set[str] = {node_id}
449
+ queue: deque[tuple[str, int]] = deque([(node_id, 0)])
450
+
451
+ while queue:
452
+ current, depth = queue.popleft()
453
+ if depth >= max_depth:
454
+ continue
455
+
456
+ neighbors: set[str] = set()
457
+ if direction in ("outgoing", "both"):
458
+ for ref in edge_index.get_outgoing(current, relationship):
459
+ neighbors.add(ref.target_id)
460
+ if direction in ("incoming", "both"):
461
+ for ref in edge_index.get_incoming(current, relationship):
462
+ neighbors.add(ref.source_id)
463
+
464
+ for neighbor in neighbors:
465
+ if neighbor not in visited:
466
+ visited.add(neighbor)
467
+ reachable.add(neighbor)
468
+ queue.append((neighbor, depth + 1))
469
+
470
+ # Intersect with the current working set
471
+ return working_set & reachable
472
+
473
+ @staticmethod
474
+ def _evaluate_conditions(node: Node, conditions: list[Condition]) -> bool:
475
+ """Evaluate a list of conditions against a node.
476
+
477
+ Reuses the ``Condition.evaluate`` method from ``query_builder.py``
478
+ so condition evaluation logic is not duplicated.
479
+
480
+ Args:
481
+ node: Node to evaluate.
482
+ conditions: Conditions to check.
483
+
484
+ Returns:
485
+ ``True`` if the node satisfies the combined conditions.
486
+ """
487
+ if not conditions:
488
+ return True
489
+
490
+ result: bool | None = None
491
+
492
+ for condition in conditions:
493
+ condition_result = condition.evaluate(node)
494
+
495
+ # Handle NOT operator
496
+ if condition.logical_op == LogicalOp.NOT:
497
+ condition_result = not condition_result
498
+
499
+ if result is None:
500
+ result = condition_result
501
+ elif condition.logical_op == LogicalOp.AND:
502
+ result = result and condition_result
503
+ elif condition.logical_op == LogicalOp.OR:
504
+ result = result or condition_result
505
+ elif condition.logical_op == LogicalOp.NOT:
506
+ # NOT combined with previous result (AND NOT)
507
+ result = result and condition_result
508
+
509
+ return result if result is not None else True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: htmlgraph
3
- Version: 0.27.2
3
+ Version: 0.27.3
4
4
  Summary: HTML is All You Need - Graph database on web standards
5
5
  Project-URL: Homepage, https://github.com/Shakes-tzd/htmlgraph
6
6
  Project-URL: Documentation, https://github.com/Shakes-tzd/htmlgraph#readme
@@ -24,6 +24,7 @@ Requires-Dist: fastapi>=0.104.0
24
24
  Requires-Dist: jinja2>=3.1.0
25
25
  Requires-Dist: justhtml>=0.6.0
26
26
  Requires-Dist: pydantic>=2.0.0
27
+ Requires-Dist: pyyaml>=6.0
27
28
  Requires-Dist: rich>=13.0.0
28
29
  Requires-Dist: typing-extensions>=4.0.0; python_version < '3.11'
29
30
  Requires-Dist: uvicorn>=0.24.0
@@ -38,7 +39,6 @@ Requires-Dist: pdoc>=14.0.0; extra == 'dev'
38
39
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
39
40
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
40
41
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
41
- Requires-Dist: pyyaml>=6.0; extra == 'dev'
42
42
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
43
43
  Requires-Dist: tomli>=2.0.0; (python_version < '3.11') and extra == 'dev'
44
44
  Provides-Extra: sqlite
@@ -1,4 +1,4 @@
1
- htmlgraph/__init__.py,sha256=o9KEEGQ4MItTgUfBT-lXHrkqHwJpqAn-WCFql6ZEDDU,6407
1
+ htmlgraph/__init__.py,sha256=pkSpF0qSd2RLviOruILG54FI3YUu0dHxiGtTt_vvGyA,6407
2
2
  htmlgraph/__init__.pyi,sha256=8JuFVuDll9jMx9s8ZQHt2tXic-geOJHiXUMB2YjmHhU,6683
3
3
  htmlgraph/agent_detection.py,sha256=wEmrDv4hssPX2OkEnJZBHPbalxcaloiJF_hOOow_5WE,3511
4
4
  htmlgraph/agent_registry.py,sha256=80TPYr4P0YMizPUbTH4N5wH6D84IKs-HPBLHGeeP6bY,9449
@@ -6,6 +6,7 @@ htmlgraph/agents.py,sha256=Yvu6x1nOfrW2WhRTAHiCuSpvqoVJXx1Mkzd59kwEczw,33466
6
6
  htmlgraph/analytics_index.py,sha256=fm0cnZjce2Ii4F9FQo1np_mWsFXuRgTTOzbrCJWGW3g,32793
7
7
  htmlgraph/atomic_ops.py,sha256=f0ULZoJThfC-A8xORvRv5OuFuhEnnH7uJrZslnZwmuw,18781
8
8
  htmlgraph/attribute_index.py,sha256=oF4TwbFGdtg1_W5AUJ-Ka4VcH-aYz-qldtiPvStJlfA,6594
9
+ htmlgraph/bounded_paths.py,sha256=4HHXiT8q2fhGkrGsGrACFnDrnAzwJ-Tk_4V9h-voenM,18832
9
10
  htmlgraph/cli_framework.py,sha256=YfAjTGIECmv_uNpkenYyc9_iKd3jXIG5OstEKpIGWJM,3551
10
11
  htmlgraph/config.py,sha256=dhOSfMfZCT-APe_uAvqABWyQ0nEhL6F0NS-15KLPZNg,6888
11
12
  htmlgraph/context_analytics.py,sha256=fGuIhGCM-500wH_pTKE24k0Rl01QvQ8Kznoj61KpjiU,11345
@@ -41,10 +42,13 @@ htmlgraph/orchestrator_mode.py,sha256=DXkG89uHe2VPz8wSx44JENn70lQiI5BLihKGpcPnNx
41
42
  htmlgraph/orchestrator_validator.py,sha256=gd_KbHsRsNEIF7EElwcxbMYqOMlyeuYIZwClASp-L-E,4699
42
43
  htmlgraph/parallel.py,sha256=txiCgNtPu9hpBsIkwHHZhKBrPrdN6W3ysA9Q0_YjSa0,22419
43
44
  htmlgraph/parser.py,sha256=mPcQ3WQdDCpflhjk8397TPbHnQcU3SzefNk-JE406UA,16570
45
+ htmlgraph/path_query.py,sha256=51ttIPsLM1RsYgRSYEpLW6lwgsFuZ9OTaIUfju3M6XU,20034
46
+ htmlgraph/pattern_matcher.py,sha256=QOEWN5QFZqUaaTywSxLVyWsjBRUBUOjBCI7fpF7Uu8M,22161
44
47
  htmlgraph/planning.py,sha256=iqPF9mCVQwOfJ4vuqcF2Y3-yhx9koJZw0cID7CknIug,35903
45
48
  htmlgraph/pydantic_models.py,sha256=4vGAp2n59Hf8v5mZG-Tx2vOSm3lcE_PypBprcejJUXE,15832
46
49
  htmlgraph/quality_gates.py,sha256=ovPrjGgAwK5MjMzEIda4a6M5_S-yGiRJYy8KhzdyTZ0,11686
47
50
  htmlgraph/query_builder.py,sha256=GBrjf_6Qoq98JxJr8cYeAMV4QpdmrfFyadJpDjH4clM,18093
51
+ htmlgraph/query_composer.py,sha256=ilasmZBc8_mLS1iq45Kcw2Ig1zCN04-r1HVKr8f9YP0,17332
48
52
  htmlgraph/reflection.py,sha256=agNfHsI1ynayTG3kgt5fiQj30LkG9b-v3wtnDsu4-Gk,15663
49
53
  htmlgraph/refs.py,sha256=jgsPWRTfPDxhH_dr4EeYdU1ly-e3HpuHgAPnMXMbaF4,10011
50
54
  htmlgraph/repo_hash.py,sha256=wYQlgYf0W1LfU95PA6vViA-MlUxA6ry_xDh7kyCB4v4,14888
@@ -79,6 +83,7 @@ htmlgraph/analytics/cost_reporter.py,sha256=_KumnYUYTr3yEHwBGGgjXSHR5IGPLDqjfuZH
79
83
  htmlgraph/analytics/cross_session.py,sha256=sD_i209uMdKzGhHb3sX7QCll-0E4JwZiZYlbcuNzdgo,20590
80
84
  htmlgraph/analytics/dependency.py,sha256=DhO5XGIZXg83w7wRw8QKSTRFyU0CwKvK9QixJ-PlLHU,25835
81
85
  htmlgraph/analytics/pattern_learning.py,sha256=TVP-kSzM7i9PCeNUYhxv8L4UUm37baunKzUFz0lUays,26689
86
+ htmlgraph/analytics/session_graph.py,sha256=3zeNH3MimcSDj5j43c2OdVh42xWTPRw6iq5xdaodR2c,23197
82
87
  htmlgraph/analytics/work_type.py,sha256=fjCEbCaHEhhzp3iyOQdOd1id5SAlnc8Y5NEhP5g5hug,19413
83
88
  htmlgraph/analytics/strategic/__init__.py,sha256=qAOjBJIrZvrGg3egKIiZDW8ZHL4OqVKUTrmX1RElHZk,2103
84
89
  htmlgraph/analytics/strategic/cost_optimizer.py,sha256=X7HuBUDkPT0-4lf6uuaCXDK0N7dOGpFYyBUgRwSYwYk,19460
@@ -87,7 +92,7 @@ htmlgraph/analytics/strategic/preference_manager.py,sha256=uO8cX-kaK7EIJh-ovXh5c
87
92
  htmlgraph/analytics/strategic/suggestion_engine.py,sha256=KV43DF6PETJkmrMA_Ni34heSdQqS_GXKeeGsq5zO6ck,26093
88
93
  htmlgraph/api/__init__.py,sha256=PPFJ5QSv-Zei8U0yGOSs8dJKtUQMeloEqsxVBfzNbbA,105
89
94
  htmlgraph/api/cost_alerts_websocket.py,sha256=6e_2pKn0-Hcz2ClFyNuJEhVBffqmcUBOkbseSgSkAqQ,14839
90
- htmlgraph/api/main.py,sha256=K8nXSbDL6MlRvZG-6OsDxvK7QfKYEcrlEHMZeVkYDGM,98099
95
+ htmlgraph/api/main.py,sha256=rQ9758JJ3qqabr8qmQDWuW2T7XlfnDWdRCydPyD9R6o,98121
91
96
  htmlgraph/api/websocket.py,sha256=L8a_p9cSLdmG1dcVQLZSfxS994Dm-caNPnYQWZ_a-Z0,17592
92
97
  htmlgraph/api/static/htmx.min.js,sha256=0VEHzH8ECp6DsbZhdv2SetQLXgJVgToD-Mz-7UbuQrA,48036
93
98
  htmlgraph/api/static/style-redesign.css,sha256=zp0ggDJHz81FN5SW5tRcWXPK_jVNCAJyqKhwlZkBfnQ,26759
@@ -321,12 +326,12 @@ htmlgraph/templates/AGENTS.md.template,sha256=f96h7V6ygwj-v-fanVI48eYMxR6t_se4be
321
326
  htmlgraph/templates/CLAUDE.md.template,sha256=h1kG2hTX2XYig2KszsHBfzrwa_4Cfcq2Pj4SwqzeDlM,1984
322
327
  htmlgraph/templates/GEMINI.md.template,sha256=gAGzE53Avki87BM_otqy5HdcYCoLsHgqaKjVzNzPMX8,1622
323
328
  htmlgraph/templates/orchestration-view.html,sha256=DlS7LlcjH0oO_KYILjuF1X42t8QhKLH4F85rkO54alY,10472
324
- htmlgraph-0.27.2.data/data/htmlgraph/dashboard.html,sha256=MUT6SaYnazoyDcvHz5hN1omYswyIoUfeoZLf2M_iblo,251268
325
- htmlgraph-0.27.2.data/data/htmlgraph/styles.css,sha256=oDUSC8jG-V-hKojOBO9J88hxAeY2wJrBYTq0uCwX_Y4,7135
326
- htmlgraph-0.27.2.data/data/htmlgraph/templates/AGENTS.md.template,sha256=f96h7V6ygwj-v-fanVI48eYMxR6t_se4bet1H4ZsDpI,7642
327
- htmlgraph-0.27.2.data/data/htmlgraph/templates/CLAUDE.md.template,sha256=h1kG2hTX2XYig2KszsHBfzrwa_4Cfcq2Pj4SwqzeDlM,1984
328
- htmlgraph-0.27.2.data/data/htmlgraph/templates/GEMINI.md.template,sha256=gAGzE53Avki87BM_otqy5HdcYCoLsHgqaKjVzNzPMX8,1622
329
- htmlgraph-0.27.2.dist-info/METADATA,sha256=gf4WGVLI615GAWSe6scFMb65ZgKXAVGAIPDIXD6nH8s,10236
330
- htmlgraph-0.27.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
331
- htmlgraph-0.27.2.dist-info/entry_points.txt,sha256=Wmdo5cx8pt6NoMsssVE2mZH1CZLSUsrg_3iSWatiyn0,103
332
- htmlgraph-0.27.2.dist-info/RECORD,,
329
+ htmlgraph-0.27.3.data/data/htmlgraph/dashboard.html,sha256=MUT6SaYnazoyDcvHz5hN1omYswyIoUfeoZLf2M_iblo,251268
330
+ htmlgraph-0.27.3.data/data/htmlgraph/styles.css,sha256=oDUSC8jG-V-hKojOBO9J88hxAeY2wJrBYTq0uCwX_Y4,7135
331
+ htmlgraph-0.27.3.data/data/htmlgraph/templates/AGENTS.md.template,sha256=f96h7V6ygwj-v-fanVI48eYMxR6t_se4bet1H4ZsDpI,7642
332
+ htmlgraph-0.27.3.data/data/htmlgraph/templates/CLAUDE.md.template,sha256=h1kG2hTX2XYig2KszsHBfzrwa_4Cfcq2Pj4SwqzeDlM,1984
333
+ htmlgraph-0.27.3.data/data/htmlgraph/templates/GEMINI.md.template,sha256=gAGzE53Avki87BM_otqy5HdcYCoLsHgqaKjVzNzPMX8,1622
334
+ htmlgraph-0.27.3.dist-info/METADATA,sha256=0pT6ydHGg3sccbKDYNDSzel1xXtjFH9-KwYnBxIQhsI,10220
335
+ htmlgraph-0.27.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
336
+ htmlgraph-0.27.3.dist-info/entry_points.txt,sha256=Wmdo5cx8pt6NoMsssVE2mZH1CZLSUsrg_3iSWatiyn0,103
337
+ htmlgraph-0.27.3.dist-info/RECORD,,