aatgraph 0.3.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.
- aat/__init__.py +74 -0
- aat/core/__init__.py +61 -0
- aat/core/cex.py +93 -0
- aat/core/coloring.py +96 -0
- aat/core/graph.py +125 -0
- aat/core/graphviz.py +171 -0
- aat/core/html.py +177 -0
- aat/core/mermaid.py +180 -0
- aat/core/orientation.py +38 -0
- aat/core/serialization.py +300 -0
- aat/core/tokens.py +41 -0
- aat/core/validate.py +65 -0
- aat/english/__init__.py +58 -0
- aat/english/dspy_signatures.py +113 -0
- aat/english/gepa_metric.py +110 -0
- aat/english/pipeline.py +125 -0
- aat/english/sentences.py +219 -0
- aat/english/token_budget.py +460 -0
- aat/english/tokenize.py +43 -0
- aat/lm_cost.py +122 -0
- aat/py.typed +0 -0
- aatgraph-0.3.0.dist-info/METADATA +65 -0
- aatgraph-0.3.0.dist-info/RECORD +26 -0
- aatgraph-0.3.0.dist-info/WHEEL +5 -0
- aatgraph-0.3.0.dist-info/licenses/LICENSE +674 -0
- aatgraph-0.3.0.dist-info/top_level.txt +1 -0
aat/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aat: a reductive Agent-Action-Target (AAT) model of natural-language
|
|
3
|
+
syntax (see aat-model.md), plus a DSPy-based pipeline applying it to
|
|
4
|
+
English (aat.english).
|
|
5
|
+
|
|
6
|
+
This top-level module re-exports only aat.core -- the language-agnostic
|
|
7
|
+
model itself -- so `import aat` never requires dspy to be installed.
|
|
8
|
+
English-specific application code lives in aat.english and is imported
|
|
9
|
+
separately:
|
|
10
|
+
|
|
11
|
+
from aat import CitableToken, AATNode, AATGraph, graph_to_mermaid # always available
|
|
12
|
+
from aat.english import analyze_passage # needs the 'english' extra
|
|
13
|
+
|
|
14
|
+
See README.md for the reasoning behind this split, and USAGE.md for
|
|
15
|
+
worked examples of both.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .core import (
|
|
19
|
+
CitableToken,
|
|
20
|
+
CitedPassage,
|
|
21
|
+
AATGraph,
|
|
22
|
+
AATNode,
|
|
23
|
+
Role,
|
|
24
|
+
ROLES,
|
|
25
|
+
validate,
|
|
26
|
+
tokens_to_html,
|
|
27
|
+
serialize_nodes,
|
|
28
|
+
write_nodes,
|
|
29
|
+
read_nodes,
|
|
30
|
+
read_graph,
|
|
31
|
+
serialize_tokens,
|
|
32
|
+
write_tokens,
|
|
33
|
+
read_tokens,
|
|
34
|
+
serialize_analysis,
|
|
35
|
+
write_analysis,
|
|
36
|
+
read_analysis,
|
|
37
|
+
graph_to_mermaid,
|
|
38
|
+
save_mermaid,
|
|
39
|
+
graph_to_dot,
|
|
40
|
+
save_dot,
|
|
41
|
+
assign_action_colors,
|
|
42
|
+
parse_cex_ctsdata,
|
|
43
|
+
read_cex_passages,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"CitableToken",
|
|
50
|
+
"CitedPassage",
|
|
51
|
+
"AATGraph",
|
|
52
|
+
"AATNode",
|
|
53
|
+
"Role",
|
|
54
|
+
"ROLES",
|
|
55
|
+
"validate",
|
|
56
|
+
"tokens_to_html",
|
|
57
|
+
"serialize_nodes",
|
|
58
|
+
"write_nodes",
|
|
59
|
+
"read_nodes",
|
|
60
|
+
"read_graph",
|
|
61
|
+
"serialize_tokens",
|
|
62
|
+
"write_tokens",
|
|
63
|
+
"read_tokens",
|
|
64
|
+
"serialize_analysis",
|
|
65
|
+
"write_analysis",
|
|
66
|
+
"read_analysis",
|
|
67
|
+
"graph_to_mermaid",
|
|
68
|
+
"save_mermaid",
|
|
69
|
+
"graph_to_dot",
|
|
70
|
+
"save_dot",
|
|
71
|
+
"assign_action_colors",
|
|
72
|
+
"parse_cex_ctsdata",
|
|
73
|
+
"read_cex_passages",
|
|
74
|
+
]
|
aat/core/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aat.core: the Agent-Action-Target model itself (aat-model.md), with no
|
|
3
|
+
dependency on any particular language or on dspy.
|
|
4
|
+
|
|
5
|
+
Everything in this subpackage represents, validates, serializes, or
|
|
6
|
+
renders an AAT graph; nothing here knows how to *produce* one from real
|
|
7
|
+
text -- that's aat.english's job (or, eventually, another language-
|
|
8
|
+
specific sibling subpackage). Keeping this boundary means aat.core can be
|
|
9
|
+
imported and reused on its own by any downstream project that just wants
|
|
10
|
+
the data model, the file format, and the Mermaid/Graphviz renderers,
|
|
11
|
+
without pulling in dspy at all.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .tokens import CitableToken, CitedPassage
|
|
15
|
+
from .graph import AATGraph, AATNode, Role, ROLES
|
|
16
|
+
from .validate import validate
|
|
17
|
+
from .serialization import (
|
|
18
|
+
serialize_nodes,
|
|
19
|
+
write_nodes,
|
|
20
|
+
read_nodes,
|
|
21
|
+
read_graph,
|
|
22
|
+
serialize_tokens,
|
|
23
|
+
write_tokens,
|
|
24
|
+
read_tokens,
|
|
25
|
+
serialize_analysis,
|
|
26
|
+
write_analysis,
|
|
27
|
+
read_analysis,
|
|
28
|
+
)
|
|
29
|
+
from .html import tokens_to_html
|
|
30
|
+
from .mermaid import graph_to_mermaid, save_mermaid
|
|
31
|
+
from .graphviz import graph_to_dot, save_dot
|
|
32
|
+
from .coloring import assign_action_colors
|
|
33
|
+
from .cex import parse_cex_ctsdata, read_cex_passages
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"CitableToken",
|
|
37
|
+
"CitedPassage",
|
|
38
|
+
"AATGraph",
|
|
39
|
+
"AATNode",
|
|
40
|
+
"Role",
|
|
41
|
+
"ROLES",
|
|
42
|
+
"validate",
|
|
43
|
+
"serialize_nodes",
|
|
44
|
+
"write_nodes",
|
|
45
|
+
"read_nodes",
|
|
46
|
+
"read_graph",
|
|
47
|
+
"serialize_tokens",
|
|
48
|
+
"write_tokens",
|
|
49
|
+
"read_tokens",
|
|
50
|
+
"serialize_analysis",
|
|
51
|
+
"write_analysis",
|
|
52
|
+
"read_analysis",
|
|
53
|
+
"tokens_to_html",
|
|
54
|
+
"graph_to_mermaid",
|
|
55
|
+
"save_mermaid",
|
|
56
|
+
"graph_to_dot",
|
|
57
|
+
"save_dot",
|
|
58
|
+
"assign_action_colors",
|
|
59
|
+
"parse_cex_ctsdata",
|
|
60
|
+
"read_cex_passages",
|
|
61
|
+
]
|
aat/core/cex.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read a corpus of citable text passages from a CEX (CITE Exchange) file --
|
|
3
|
+
an external plain-text interchange format from the CITE architecture
|
|
4
|
+
(https://cite-architecture.github.io/citedx/CEX-spec-3.0.1/), not this
|
|
5
|
+
project's own aat.core.serialization format (a different, purpose-built
|
|
6
|
+
convention -- see that module's own docstring). A CEX file can carry many
|
|
7
|
+
kinds of data in labelled '#!'-blocks ('#!citelibrary', '#!ctscatalog',
|
|
8
|
+
'#!imagedata', ...); this module reads only the one block aat cares
|
|
9
|
+
about -- '#!ctsdata' -- and ignores every other block type entirely, the
|
|
10
|
+
same way aat.core.serialization's own readers ignore block types other
|
|
11
|
+
than their own.
|
|
12
|
+
|
|
13
|
+
A '#!ctsdata' block is two columns per line -- a CTS URN, then that
|
|
14
|
+
node's own text -- separated by a delimiter the file's own author chose:
|
|
15
|
+
per the CEX spec, the delimiter is never declared inside the file itself,
|
|
16
|
+
just used consistently once picked. '#' is the most common convention in
|
|
17
|
+
practice (and this module's own default) -- pass `delimiter` for a file
|
|
18
|
+
that uses something else. Blank lines are ignored, and a line starting
|
|
19
|
+
with '//' is a CEX comment and is also ignored -- both per the CEX spec.
|
|
20
|
+
|
|
21
|
+
Splitting is done on the delimiter's *first* occurrence per line, not a
|
|
22
|
+
strict two-column check (contrast aat.core.serialization's own readers,
|
|
23
|
+
which require an exact column count) -- a CTS URN never contains '#'
|
|
24
|
+
(or any other reasonable delimiter choice), but the citable text half of
|
|
25
|
+
the line is real prose this module doesn't control, and (especially with
|
|
26
|
+
the default '#' delimiter) can plausibly contain the delimiter character
|
|
27
|
+
again somewhere in the text itself. Splitting on the first occurrence
|
|
28
|
+
only keeps that text intact rather than raising over it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from typing import List
|
|
32
|
+
|
|
33
|
+
from .tokens import CitedPassage
|
|
34
|
+
|
|
35
|
+
CTSDATA_LABEL = "#!ctsdata"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_cex_ctsdata(text: str, delimiter: str = "#") -> List[CitedPassage]:
|
|
39
|
+
"""Parse every '#!ctsdata' block in `text` (a CEX file's own full
|
|
40
|
+
contents, already read into a string) and return its rows,
|
|
41
|
+
concatenated in file order, as a list of CitedPassage -- `context` is
|
|
42
|
+
each row's own CTS URN, `text` is its citable text. Every other CEX
|
|
43
|
+
block type is ignored entirely, whether it appears before, between,
|
|
44
|
+
or after the '#!ctsdata' block(s); multiple '#!ctsdata' blocks in one
|
|
45
|
+
file are concatenated, in file order, same as
|
|
46
|
+
aat.core.serialization's own multi-block handling.
|
|
47
|
+
|
|
48
|
+
Raises ValueError, naming the offending line, for a '#!ctsdata' row
|
|
49
|
+
that has no `delimiter` in it at all -- most often a sign `delimiter`
|
|
50
|
+
is wrong for this particular file. Raises ValueError (not returning
|
|
51
|
+
an empty list) if the file has no '#!ctsdata' block at all, so a
|
|
52
|
+
caller can't mistake "wrong file" for "file with zero passages" --
|
|
53
|
+
same reasoning as aat.core.serialization.read_nodes()/read_tokens().
|
|
54
|
+
"""
|
|
55
|
+
passages: List[CitedPassage] = []
|
|
56
|
+
in_ctsdata = False
|
|
57
|
+
seen = False
|
|
58
|
+
|
|
59
|
+
for line_no, raw_line in enumerate(text.splitlines(), start=1):
|
|
60
|
+
line = raw_line.strip("\r\n")
|
|
61
|
+
if line == "" or line.startswith("//"):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if line.startswith("#!"):
|
|
65
|
+
in_ctsdata = line == CTSDATA_LABEL
|
|
66
|
+
if in_ctsdata:
|
|
67
|
+
seen = True
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if not in_ctsdata:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
urn, sep, passage_text = line.partition(delimiter)
|
|
74
|
+
if not sep:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"line {line_no}: no {delimiter!r} delimiter found in a "
|
|
77
|
+
f"{CTSDATA_LABEL!r} row -- wrong --delimiter for this file? {line!r}"
|
|
78
|
+
)
|
|
79
|
+
passages.append(CitedPassage(context=urn, text=passage_text))
|
|
80
|
+
|
|
81
|
+
if not seen:
|
|
82
|
+
raise ValueError(f"file has no {CTSDATA_LABEL!r} block")
|
|
83
|
+
|
|
84
|
+
return passages
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_cex_passages(path: str, delimiter: str = "#") -> List[CitedPassage]:
|
|
88
|
+
"""Read `path` as a CEX file and return parse_cex_ctsdata() of its
|
|
89
|
+
contents -- see that function's own docstring for the exact format
|
|
90
|
+
and error cases."""
|
|
91
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
92
|
+
text = f.read()
|
|
93
|
+
return parse_cex_ctsdata(text, delimiter=delimiter)
|
aat/core/coloring.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared color-by-action-cluster assignment.
|
|
3
|
+
|
|
4
|
+
Every action node, plus every agent or target node whose `related_node`
|
|
5
|
+
points at it, shares one color -- assigned from a small palette in the
|
|
6
|
+
order each action first appears in a graph's own node list. This lives in
|
|
7
|
+
its own module, separate from mermaid.py, so it can be reused wherever a
|
|
8
|
+
token or node needs to be colored the same way it is in a Mermaid diagram
|
|
9
|
+
without depending on Mermaid-specific rendering code -- e.g.
|
|
10
|
+
aat.core.html's tokens_to_html(), which highlights passage text with
|
|
11
|
+
these same colors. This mirrors why arsgrammatica's verbal_units.py is
|
|
12
|
+
shared between its own mermaid.py and rendering.py.
|
|
13
|
+
|
|
14
|
+
A dependent action gets its OWN color, not its governor's -- it's still
|
|
15
|
+
its own cluster's anchor, even though something else (a Mermaid edge, an
|
|
16
|
+
HTML border) may also point at or otherwise reference another cluster's
|
|
17
|
+
anchor.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Dict, List, Tuple
|
|
21
|
+
|
|
22
|
+
from .graph import AATGraph, AATNode
|
|
23
|
+
|
|
24
|
+
NodeKey = Tuple[str, str]
|
|
25
|
+
ColorTriple = Tuple[str, str, str]
|
|
26
|
+
|
|
27
|
+
# (fill, stroke, text) hex triples, chosen for readable contrast between
|
|
28
|
+
# fill and text in both light- and dark-themed renderers. Cycles (with a
|
|
29
|
+
# warning -- see assign_action_colors()'s own docstring) if a graph has
|
|
30
|
+
# more distinct actions than this palette has slots.
|
|
31
|
+
_ACTION_COLOR_PALETTE: List[ColorTriple] = [
|
|
32
|
+
("#E3F2FD", "#1565C0", "#0D47A1"), # blue
|
|
33
|
+
("#FFF3E0", "#EF6C00", "#E65100"), # orange
|
|
34
|
+
("#E8F5E9", "#2E7D32", "#1B5E20"), # green
|
|
35
|
+
("#FCE4EC", "#AD1457", "#880E4F"), # pink
|
|
36
|
+
("#EDE7F6", "#5E35B1", "#4527A0"), # purple
|
|
37
|
+
("#FFFDE7", "#F9A825", "#F57F17"), # yellow
|
|
38
|
+
("#E0F7FA", "#00838F", "#006064"), # cyan
|
|
39
|
+
("#FBE9E7", "#D84315", "#BF360C"), # deep orange
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _node_key(node: AATNode) -> NodeKey:
|
|
44
|
+
return (node.context, node.id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def assign_action_colors(graph: AATGraph) -> Tuple[Dict[NodeKey, ColorTriple], List[str]]:
|
|
48
|
+
"""Return (color_of_node, warnings).
|
|
49
|
+
|
|
50
|
+
`color_of_node` maps every node's (context, id) to a (fill, stroke,
|
|
51
|
+
text) hex triple, for every node that clusters with some action: an
|
|
52
|
+
action node itself (dependent or not -- see this module's own
|
|
53
|
+
docstring), or an agent/target node whose `related_node` resolves to
|
|
54
|
+
an action node in the same context. A node with no resolvable cluster
|
|
55
|
+
(e.g. an agent/target with a broken related_node -- shouldn't happen
|
|
56
|
+
in a graph that's passed aat.core.validate.validate()) is simply
|
|
57
|
+
absent from the mapping.
|
|
58
|
+
|
|
59
|
+
Colors are assigned to actions in the order they first appear in
|
|
60
|
+
`graph.nodes`, cycling through the palette (currently 8 colors) if
|
|
61
|
+
there are more distinct actions than that -- in which case two
|
|
62
|
+
distinct actions (and their own agent/target nodes) end up sharing the
|
|
63
|
+
identical color. `warnings` has one entry naming this when it happens;
|
|
64
|
+
otherwise it's empty.
|
|
65
|
+
"""
|
|
66
|
+
actions = [n for n in graph.nodes if n.role == "action"]
|
|
67
|
+
|
|
68
|
+
warnings: List[str] = []
|
|
69
|
+
if len(actions) > len(_ACTION_COLOR_PALETTE):
|
|
70
|
+
warnings.append(
|
|
71
|
+
f"{len(actions)} actions but only {len(_ACTION_COLOR_PALETTE)} palette "
|
|
72
|
+
"colors -- colors repeat and some distinct actions will share a color"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
action_index: Dict[NodeKey, int] = {_node_key(a): i for i, a in enumerate(actions)}
|
|
76
|
+
|
|
77
|
+
by_key: Dict[NodeKey, AATNode] = {_node_key(n): n for n in graph.nodes}
|
|
78
|
+
cluster_of_node: Dict[NodeKey, NodeKey] = {}
|
|
79
|
+
for node in graph.nodes:
|
|
80
|
+
key = _node_key(node)
|
|
81
|
+
if node.role == "action":
|
|
82
|
+
cluster_of_node[key] = key
|
|
83
|
+
elif node.related_node is not None:
|
|
84
|
+
related_key = (node.context, node.related_node)
|
|
85
|
+
if related_key in by_key:
|
|
86
|
+
cluster_of_node[key] = related_key
|
|
87
|
+
|
|
88
|
+
color_of_node: Dict[NodeKey, ColorTriple] = {}
|
|
89
|
+
for node in graph.nodes:
|
|
90
|
+
key = _node_key(node)
|
|
91
|
+
cluster_key = cluster_of_node.get(key)
|
|
92
|
+
if cluster_key is None or cluster_key not in action_index:
|
|
93
|
+
continue
|
|
94
|
+
color_of_node[key] = _ACTION_COLOR_PALETTE[action_index[cluster_key] % len(_ACTION_COLOR_PALETTE)]
|
|
95
|
+
|
|
96
|
+
return color_of_node, warnings
|
aat/core/graph.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The AAT graph itself: the output side of the AAT model (aat-model.md, "The
|
|
3
|
+
AAT graph for English" -- despite that section header, the graph *shape*
|
|
4
|
+
it defines is not English-specific; only the rules for how to populate it
|
|
5
|
+
from English text are English-specific. See aat.english for those rules.)
|
|
6
|
+
|
|
7
|
+
An AATGraph is a selection of nodes drawn from a passage's citable tokens.
|
|
8
|
+
Every node keeps the context/id/value of the token(s) it derives from (for
|
|
9
|
+
a compound action, see AATNode's own docstring on id/value), and adds two
|
|
10
|
+
fields the raw tokens don't have: `role` (agent/action/target) and
|
|
11
|
+
`related_node`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import List, Literal, Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
Role = Literal["agent", "action", "target"]
|
|
19
|
+
|
|
20
|
+
ROLES: tuple = ("agent", "action", "target")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AATNode(BaseModel):
|
|
24
|
+
"""One node in an AAT graph.
|
|
25
|
+
|
|
26
|
+
`context` and `id` identify the token this node is built from -- for a
|
|
27
|
+
single-token action, an agent, or a target, `id` is just that token's
|
|
28
|
+
own id. For a *compound* action (aat-model.md's "Actions" section --
|
|
29
|
+
e.g. "was eating"), `id` is instead the id of the *principal verb*
|
|
30
|
+
token within the compound (e.g. the token for "eating"), and `value`
|
|
31
|
+
is the space-joined text of every component token in surface order
|
|
32
|
+
(e.g. "was eating"), not just the principal verb's own text.
|
|
33
|
+
|
|
34
|
+
`related_node` is:
|
|
35
|
+
- `None`, for an *independent* action node (aat-model.md's
|
|
36
|
+
"Actions" section);
|
|
37
|
+
- another action node's `id`, for a *dependent* action node (the id
|
|
38
|
+
of the governing action it depends on);
|
|
39
|
+
- another action node's `id`, for an agent or target node (the id
|
|
40
|
+
of the action it's the agent/target of) -- agent and target nodes
|
|
41
|
+
always have a related_node; only an action node's related_node
|
|
42
|
+
can be `None`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
context: str = Field(description="Context reference this node belongs to.")
|
|
46
|
+
id: str = Field(
|
|
47
|
+
description=(
|
|
48
|
+
"Token id this node is built from -- the principal verb's id "
|
|
49
|
+
"for a compound action, otherwise the single underlying "
|
|
50
|
+
"token's id."
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
value: str = Field(
|
|
54
|
+
description=(
|
|
55
|
+
"This node's string value -- a single token's own text, or, "
|
|
56
|
+
"for a compound action, every component token's text joined "
|
|
57
|
+
"by spaces in surface order."
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
role: Role = Field(description="'agent', 'action', or 'target'.")
|
|
61
|
+
related_node: Optional[str] = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description=(
|
|
64
|
+
"For an action node: the id of the governing action node, if "
|
|
65
|
+
"this action is dependent/subordinate; None if independent. "
|
|
66
|
+
"For an agent or target node: the id of the action node it "
|
|
67
|
+
"relates to (always set)."
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AATGraph(BaseModel):
|
|
73
|
+
"""A full AAT graph for one or more citable passages: an ordered list
|
|
74
|
+
of AATNode. Node order is not semantically significant -- the
|
|
75
|
+
convenience accessors below don't depend on it -- but callers that
|
|
76
|
+
built the graph from a specific passage will typically find document
|
|
77
|
+
order convenient for display."""
|
|
78
|
+
|
|
79
|
+
nodes: List[AATNode] = Field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
def by_id(self, context: str, id: str) -> Optional[AATNode]:
|
|
82
|
+
"""The node with this (context, id), or None. `id` alone isn't a
|
|
83
|
+
reliable key across contexts (CitableToken/AATNode ids are only
|
|
84
|
+
unique *within* one context -- see CitableToken's docstring), so
|
|
85
|
+
both are required here."""
|
|
86
|
+
for node in self.nodes:
|
|
87
|
+
if node.context == context and node.id == id:
|
|
88
|
+
return node
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def actions(self) -> List[AATNode]:
|
|
92
|
+
"""Every action node, in list order."""
|
|
93
|
+
return [n for n in self.nodes if n.role == "action"]
|
|
94
|
+
|
|
95
|
+
def agents(self) -> List[AATNode]:
|
|
96
|
+
"""Every agent node, in list order."""
|
|
97
|
+
return [n for n in self.nodes if n.role == "agent"]
|
|
98
|
+
|
|
99
|
+
def targets(self) -> List[AATNode]:
|
|
100
|
+
"""Every target node, in list order."""
|
|
101
|
+
return [n for n in self.nodes if n.role == "target"]
|
|
102
|
+
|
|
103
|
+
def agents_for(self, action: AATNode) -> List[AATNode]:
|
|
104
|
+
"""Every agent node whose related_node points at `action`'s id,
|
|
105
|
+
within the same context."""
|
|
106
|
+
return [
|
|
107
|
+
n for n in self.nodes
|
|
108
|
+
if n.role == "agent" and n.context == action.context and n.related_node == action.id
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
def targets_for(self, action: AATNode) -> List[AATNode]:
|
|
112
|
+
"""Every target node whose related_node points at `action`'s id,
|
|
113
|
+
within the same context."""
|
|
114
|
+
return [
|
|
115
|
+
n for n in self.nodes
|
|
116
|
+
if n.role == "target" and n.context == action.context and n.related_node == action.id
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def governing_action(self, action: AATNode) -> Optional[AATNode]:
|
|
120
|
+
"""For a dependent action node, the action node it's subordinate
|
|
121
|
+
to; None if `action` is independent (related_node is None) or if
|
|
122
|
+
related_node doesn't resolve to any node in this graph."""
|
|
123
|
+
if action.related_node is None:
|
|
124
|
+
return None
|
|
125
|
+
return self.by_id(action.context, action.related_node)
|
aat/core/graphviz.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render an AATGraph as a Graphviz DOT digraph -- the same graph
|
|
3
|
+
aat.core.mermaid.graph_to_mermaid() renders as a Mermaid flowchart,
|
|
4
|
+
translated into Graphviz's own syntax instead. The underlying node/edge/
|
|
5
|
+
coloring model is identical (see that module's own docstring); only the
|
|
6
|
+
target syntax differs:
|
|
7
|
+
|
|
8
|
+
- Every node becomes a DOT node, labelled with its own `value` and
|
|
9
|
+
shaped by its `role`. Graphviz has no shape literally called
|
|
10
|
+
"stadium", so target's shape is chosen as the closest visual analogue
|
|
11
|
+
to Mermaid's `([...])` (fully rounded, no square corners at all):
|
|
12
|
+
- action: `shape=box` (plain rectangle, matches Mermaid's `[...]`)
|
|
13
|
+
- agent: `shape=box, style=rounded` (rounded-corner rectangle,
|
|
14
|
+
matches Mermaid's `(...)`)
|
|
15
|
+
- target: `shape=ellipse` (fully rounded oval, matches Mermaid's
|
|
16
|
+
`([...])`)
|
|
17
|
+
- Every node with a `related_node` becomes a labelled edge FROM that node
|
|
18
|
+
TO the node it relates to, exactly as graph_to_mermaid() describes.
|
|
19
|
+
- By default (`color_by_action=True`), every node is colored by the same
|
|
20
|
+
action-cluster assignment graph_to_mermaid() uses
|
|
21
|
+
(aat.core.coloring.assign_action_colors()) -- but applied as inline
|
|
22
|
+
`style=filled, fillcolor=..., color=..., fontcolor=...` attributes
|
|
23
|
+
directly on each node's own line. DOT has no equivalent to Mermaid's
|
|
24
|
+
separate classDef/class mechanism, so there's no separate "class"
|
|
25
|
+
grouping step here; each node just carries its own color attributes.
|
|
26
|
+
|
|
27
|
+
Multiple contexts in one `graph` are all drawn into a single digraph,
|
|
28
|
+
with no special separation between them -- same caveat as
|
|
29
|
+
graph_to_mermaid(); filter `graph.nodes` first for one digraph per
|
|
30
|
+
context.
|
|
31
|
+
|
|
32
|
+
`orientation` (default "BT", bottom-to-top) is validated the same way
|
|
33
|
+
graph_to_mermaid() validates it (aat.core.orientation, shared between
|
|
34
|
+
both renderers) -- but Graphviz's `rankdir` graph attribute has no "TD"
|
|
35
|
+
synonym of its own, so an orientation of "TD" (top-down) is mapped to
|
|
36
|
+
Graphviz's "TB" (top-to-bottom -- the same direction, just Graphviz's own
|
|
37
|
+
spelling of it) when it's written out; see graph_to_dot()'s own
|
|
38
|
+
docstring.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from typing import Dict, List, Tuple
|
|
42
|
+
|
|
43
|
+
from .coloring import ColorTriple, assign_action_colors
|
|
44
|
+
from .graph import AATGraph, AATNode
|
|
45
|
+
from .orientation import normalize_orientation
|
|
46
|
+
|
|
47
|
+
# Graphviz node shape/style, keyed by AATNode.role -- see this module's
|
|
48
|
+
# own docstring for why target maps to shape=ellipse rather than a
|
|
49
|
+
# "stadium" Graphviz has no name for. A role this module doesn't
|
|
50
|
+
# recognize (shouldn't happen -- Role is a Literal of exactly these
|
|
51
|
+
# three -- but AATNode itself doesn't enforce that at the type level for
|
|
52
|
+
# a hand-built or deserialized node) falls back to a plain box, same
|
|
53
|
+
# fallback graph_to_mermaid() uses for an unrecognized role.
|
|
54
|
+
_ROLE_SHAPE = {
|
|
55
|
+
"action": "box",
|
|
56
|
+
"agent": "box",
|
|
57
|
+
"target": "ellipse",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Extra `style=` keywords layered on top of a node's own shape, keyed the
|
|
61
|
+
# same way -- only agent needs one of its own (rounded corners); a
|
|
62
|
+
# color_by_action fill (see graph_to_dot()) adds "filled" to whatever's
|
|
63
|
+
# here rather than overwriting it, so a colored agent node still ends up
|
|
64
|
+
# rounded (style="rounded,filled"), not a plain filled rectangle.
|
|
65
|
+
_ROLE_EXTRA_STYLES: Dict[str, List[str]] = {
|
|
66
|
+
"agent": ["rounded"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _node_key(node: AATNode) -> Tuple[str, str]:
|
|
71
|
+
return (node.context, node.id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _escape_label(text: str) -> str:
|
|
75
|
+
"""Escape `text` for use inside a DOT double-quoted string literal --
|
|
76
|
+
backslash first (so escaping the quote below doesn't get re-escaped
|
|
77
|
+
itself), then the quote character."""
|
|
78
|
+
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def graph_to_dot(
|
|
82
|
+
graph: AATGraph,
|
|
83
|
+
orientation: str = "BT",
|
|
84
|
+
color_by_action: bool = True,
|
|
85
|
+
) -> Tuple[str, List[str]]:
|
|
86
|
+
"""Build a Graphviz DOT digraph from an AATGraph -- see this module's
|
|
87
|
+
own docstring for the node-shape/edge/coloring mapping, which mirrors
|
|
88
|
+
aat.core.mermaid.graph_to_mermaid() exactly except for the target
|
|
89
|
+
syntax.
|
|
90
|
+
|
|
91
|
+
`orientation` is validated exactly like graph_to_mermaid()'s (see
|
|
92
|
+
aat.core.orientation) -- `BT` (bottom-to-top, the default here),
|
|
93
|
+
`TB`/`TD` (top-down -- synonyms), `LR`, or `RL`. Written out as
|
|
94
|
+
Graphviz's own `rankdir` graph attribute; since `rankdir` has no
|
|
95
|
+
"TD" of its own, "TD" is mapped to "TB" here (same direction, just
|
|
96
|
+
Graphviz's own name for it) -- every other value is used verbatim.
|
|
97
|
+
Anything outside that vocabulary raises `ValueError` naming the
|
|
98
|
+
valid options, rather than silently producing invalid DOT syntax.
|
|
99
|
+
|
|
100
|
+
`color_by_action` (default True) -- see this module's own docstring.
|
|
101
|
+
Pass False for a plain, uncolored digraph.
|
|
102
|
+
|
|
103
|
+
Returns (dot_text, warnings) -- same warning cases as
|
|
104
|
+
graph_to_mermaid(): a node whose `related_node` doesn't resolve to
|
|
105
|
+
another node actually present in `graph` (same context) is still
|
|
106
|
+
drawn, but its edge is skipped and reported as a warning; and, if
|
|
107
|
+
`color_by_action` is True and the graph has more distinct actions
|
|
108
|
+
than the palette has colors (currently 8), one warning notes that
|
|
109
|
+
colors repeat.
|
|
110
|
+
"""
|
|
111
|
+
orientation = normalize_orientation(orientation)
|
|
112
|
+
rankdir = "TB" if orientation == "TD" else orientation
|
|
113
|
+
|
|
114
|
+
by_key: Dict[Tuple[str, str], AATNode] = {_node_key(n): n for n in graph.nodes}
|
|
115
|
+
|
|
116
|
+
color_of_node: Dict[Tuple[str, str], ColorTriple] = {}
|
|
117
|
+
warnings: List[str] = []
|
|
118
|
+
if color_by_action:
|
|
119
|
+
color_of_node, color_warnings = assign_action_colors(graph)
|
|
120
|
+
warnings.extend(color_warnings)
|
|
121
|
+
|
|
122
|
+
lines = ["digraph aat {", f" rankdir={rankdir};"]
|
|
123
|
+
|
|
124
|
+
for node in graph.nodes:
|
|
125
|
+
attrs = [f"shape={_ROLE_SHAPE.get(node.role, 'box')}"]
|
|
126
|
+
styles = list(_ROLE_EXTRA_STYLES.get(node.role, []))
|
|
127
|
+
|
|
128
|
+
color = color_of_node.get(_node_key(node))
|
|
129
|
+
if color is not None:
|
|
130
|
+
fill, stroke, text = color
|
|
131
|
+
styles.append("filled")
|
|
132
|
+
attrs.append(f'fillcolor="{fill}"')
|
|
133
|
+
attrs.append(f'color="{stroke}"')
|
|
134
|
+
attrs.append(f'fontcolor="{text}"')
|
|
135
|
+
|
|
136
|
+
if styles:
|
|
137
|
+
attrs.append(f'style="{",".join(styles)}"')
|
|
138
|
+
attrs.append(f'label="{_escape_label(node.value)}"')
|
|
139
|
+
|
|
140
|
+
lines.append(f' {node.id} [{", ".join(attrs)}];')
|
|
141
|
+
|
|
142
|
+
for node in graph.nodes:
|
|
143
|
+
if node.related_node is None:
|
|
144
|
+
continue
|
|
145
|
+
target_key = (node.context, node.related_node)
|
|
146
|
+
if target_key not in by_key:
|
|
147
|
+
warnings.append(
|
|
148
|
+
f"skipped edge {node.id} -[{node.role}]-> {node.related_node}: "
|
|
149
|
+
"target is not a node in this graph"
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
edge_label = "dependent" if node.role == "action" else node.role
|
|
153
|
+
lines.append(f' {node.id} -> {node.related_node} [label="{edge_label}"];')
|
|
154
|
+
|
|
155
|
+
lines.append("}")
|
|
156
|
+
return "\n".join(lines), warnings
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def save_dot(
|
|
160
|
+
graph: AATGraph,
|
|
161
|
+
path: str,
|
|
162
|
+
orientation: str = "BT",
|
|
163
|
+
color_by_action: bool = True,
|
|
164
|
+
) -> List[str]:
|
|
165
|
+
"""Write the digraph to `path` (e.g. 'analysis.dot') and return any
|
|
166
|
+
warnings from graph_to_dot(). `orientation` is validated the same
|
|
167
|
+
way -- see graph_to_dot()'s own docstring."""
|
|
168
|
+
dot_text, warnings = graph_to_dot(graph, orientation=orientation, color_by_action=color_by_action)
|
|
169
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
170
|
+
f.write(dot_text + "\n")
|
|
171
|
+
return warnings
|