aat.core
aat.core: the Agent-Action-Target model itself (aat-model.md), with no dependency on any particular language or on dspy.
Everything in this subpackage represents, validates, serializes, or renders an AAT graph; nothing here knows how to produce one from real text -- that's aat.english's job (or, eventually, another language- specific sibling subpackage). Keeping this boundary means aat.core can be imported and reused on its own by any downstream project that just wants the data model, the file format, and the Mermaid/Graphviz renderers, without pulling in dspy at all.
1""" 2aat.core: the Agent-Action-Target model itself (aat-model.md), with no 3dependency on any particular language or on dspy. 4 5Everything in this subpackage represents, validates, serializes, or 6renders an AAT graph; nothing here knows how to *produce* one from real 7text -- that's aat.english's job (or, eventually, another language- 8specific sibling subpackage). Keeping this boundary means aat.core can be 9imported and reused on its own by any downstream project that just wants 10the data model, the file format, and the Mermaid/Graphviz renderers, 11without pulling in dspy at all. 12""" 13 14from .tokens import CitableToken, CitedPassage 15from .graph import AATGraph, AATNode, Role, ROLES 16from .validate import validate 17from .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) 29from .html import tokens_to_html 30from .mermaid import graph_to_mermaid, save_mermaid 31from .graphviz import graph_to_dot, save_dot 32from .coloring import assign_action_colors 33from .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]
30class CitableToken(BaseModel): 31 """One token belonging to a citable passage. 32 33 `id` is unique *within* `context`, not globally -- aat-model.md's own 34 worked example uses a per-context sequence like 't1', 't2', ..., 35 starting over for each new context. A caller working across multiple 36 contexts should treat (context, id) as the real compound key, not id 37 alone -- see AATGraph.by_id(). 38 """ 39 40 context: str = Field(description="Context reference this token belongs to, matching its source CitedPassage.") 41 id: str = Field(description="Token id, unique within `context` (e.g. 't1', 't2', ...).") 42 value: str = Field(description="The token's own string value (its surface text).")
One token belonging to a citable passage.
id is unique within context, not globally -- aat-model.md's own
worked example uses a per-context sequence like 't1', 't2', ...,
starting over for each new context. A caller working across multiple
contexts should treat (context, id) as the real compound key, not id
alone -- see AATGraph.by_id().
20class CitedPassage(BaseModel): 21 """One citable passage of text: a reference identifying its context 22 (aat-model.md's example is a CTS URN, but any stable identifier a 23 caller chooses works -- nothing here assumes CTS specifically) paired 24 with the passage's own raw text.""" 25 26 context: str = Field(description="Reference identifying this passage's context, e.g. a CTS URN.") 27 text: str = Field(description="This passage's raw text, exactly as written.")
One citable passage of text: a reference identifying its context (aat-model.md's example is a CTS URN, but any stable identifier a caller chooses works -- nothing here assumes CTS specifically) paired with the passage's own raw text.
73class AATGraph(BaseModel): 74 """A full AAT graph for one or more citable passages: an ordered list 75 of AATNode. Node order is not semantically significant -- the 76 convenience accessors below don't depend on it -- but callers that 77 built the graph from a specific passage will typically find document 78 order convenient for display.""" 79 80 nodes: List[AATNode] = Field(default_factory=list) 81 82 def by_id(self, context: str, id: str) -> Optional[AATNode]: 83 """The node with this (context, id), or None. `id` alone isn't a 84 reliable key across contexts (CitableToken/AATNode ids are only 85 unique *within* one context -- see CitableToken's docstring), so 86 both are required here.""" 87 for node in self.nodes: 88 if node.context == context and node.id == id: 89 return node 90 return None 91 92 def actions(self) -> List[AATNode]: 93 """Every action node, in list order.""" 94 return [n for n in self.nodes if n.role == "action"] 95 96 def agents(self) -> List[AATNode]: 97 """Every agent node, in list order.""" 98 return [n for n in self.nodes if n.role == "agent"] 99 100 def targets(self) -> List[AATNode]: 101 """Every target node, in list order.""" 102 return [n for n in self.nodes if n.role == "target"] 103 104 def agents_for(self, action: AATNode) -> List[AATNode]: 105 """Every agent node whose related_node points at `action`'s id, 106 within the same context.""" 107 return [ 108 n for n in self.nodes 109 if n.role == "agent" and n.context == action.context and n.related_node == action.id 110 ] 111 112 def targets_for(self, action: AATNode) -> List[AATNode]: 113 """Every target node whose related_node points at `action`'s id, 114 within the same context.""" 115 return [ 116 n for n in self.nodes 117 if n.role == "target" and n.context == action.context and n.related_node == action.id 118 ] 119 120 def governing_action(self, action: AATNode) -> Optional[AATNode]: 121 """For a dependent action node, the action node it's subordinate 122 to; None if `action` is independent (related_node is None) or if 123 related_node doesn't resolve to any node in this graph.""" 124 if action.related_node is None: 125 return None 126 return self.by_id(action.context, action.related_node)
A full AAT graph for one or more citable passages: an ordered list of AATNode. Node order is not semantically significant -- the convenience accessors below don't depend on it -- but callers that built the graph from a specific passage will typically find document order convenient for display.
82 def by_id(self, context: str, id: str) -> Optional[AATNode]: 83 """The node with this (context, id), or None. `id` alone isn't a 84 reliable key across contexts (CitableToken/AATNode ids are only 85 unique *within* one context -- see CitableToken's docstring), so 86 both are required here.""" 87 for node in self.nodes: 88 if node.context == context and node.id == id: 89 return node 90 return None
The node with this (context, id), or None. id alone isn't a
reliable key across contexts (CitableToken/AATNode ids are only
unique within one context -- see CitableToken's docstring), so
both are required here.
92 def actions(self) -> List[AATNode]: 93 """Every action node, in list order.""" 94 return [n for n in self.nodes if n.role == "action"]
Every action node, in list order.
96 def agents(self) -> List[AATNode]: 97 """Every agent node, in list order.""" 98 return [n for n in self.nodes if n.role == "agent"]
Every agent node, in list order.
100 def targets(self) -> List[AATNode]: 101 """Every target node, in list order.""" 102 return [n for n in self.nodes if n.role == "target"]
Every target node, in list order.
104 def agents_for(self, action: AATNode) -> List[AATNode]: 105 """Every agent node whose related_node points at `action`'s id, 106 within the same context.""" 107 return [ 108 n for n in self.nodes 109 if n.role == "agent" and n.context == action.context and n.related_node == action.id 110 ]
Every agent node whose related_node points at action's id,
within the same context.
112 def targets_for(self, action: AATNode) -> List[AATNode]: 113 """Every target node whose related_node points at `action`'s id, 114 within the same context.""" 115 return [ 116 n for n in self.nodes 117 if n.role == "target" and n.context == action.context and n.related_node == action.id 118 ]
Every target node whose related_node points at action's id,
within the same context.
120 def governing_action(self, action: AATNode) -> Optional[AATNode]: 121 """For a dependent action node, the action node it's subordinate 122 to; None if `action` is independent (related_node is None) or if 123 related_node doesn't resolve to any node in this graph.""" 124 if action.related_node is None: 125 return None 126 return self.by_id(action.context, action.related_node)
For a dependent action node, the action node it's subordinate
to; None if action is independent (related_node is None) or if
related_node doesn't resolve to any node in this graph.
24class AATNode(BaseModel): 25 """One node in an AAT graph. 26 27 `context` and `id` identify the token this node is built from -- for a 28 single-token action, an agent, or a target, `id` is just that token's 29 own id. For a *compound* action (aat-model.md's "Actions" section -- 30 e.g. "was eating"), `id` is instead the id of the *principal verb* 31 token within the compound (e.g. the token for "eating"), and `value` 32 is the space-joined text of every component token in surface order 33 (e.g. "was eating"), not just the principal verb's own text. 34 35 `related_node` is: 36 - `None`, for an *independent* action node (aat-model.md's 37 "Actions" section); 38 - another action node's `id`, for a *dependent* action node (the id 39 of the governing action it depends on); 40 - another action node's `id`, for an agent or target node (the id 41 of the action it's the agent/target of) -- agent and target nodes 42 always have a related_node; only an action node's related_node 43 can be `None`. 44 """ 45 46 context: str = Field(description="Context reference this node belongs to.") 47 id: str = Field( 48 description=( 49 "Token id this node is built from -- the principal verb's id " 50 "for a compound action, otherwise the single underlying " 51 "token's id." 52 ) 53 ) 54 value: str = Field( 55 description=( 56 "This node's string value -- a single token's own text, or, " 57 "for a compound action, every component token's text joined " 58 "by spaces in surface order." 59 ) 60 ) 61 role: Role = Field(description="'agent', 'action', or 'target'.") 62 related_node: Optional[str] = Field( 63 default=None, 64 description=( 65 "For an action node: the id of the governing action node, if " 66 "this action is dependent/subordinate; None if independent. " 67 "For an agent or target node: the id of the action node it " 68 "relates to (always set)." 69 ), 70 )
One node in an AAT graph.
context and id identify the token this node is built from -- for a
single-token action, an agent, or a target, id is just that token's
own id. For a compound action (aat-model.md's "Actions" section --
e.g. "was eating"), id is instead the id of the principal verb
token within the compound (e.g. the token for "eating"), and value
is the space-joined text of every component token in surface order
(e.g. "was eating"), not just the principal verb's own text.
related_node is:
None, for an independent action node (aat-model.md's "Actions" section);- another action node's
id, for a dependent action node (the id of the governing action it depends on); - another action node's
id, for an agent or target node (the id of the action it's the agent/target of) -- agent and target nodes always have a related_node; only an action node's related_node can beNone.
Token id this node is built from -- the principal verb's id for a compound action, otherwise the single underlying token's id.
16def validate(tokens: List[CitableToken], graph: AATGraph) -> List[str]: 17 """Return a list of human-readable problem descriptions found in 18 `graph`, given the `tokens` it was built from. An empty list means no 19 referential problems were found (not that the analysis is correct). 20 21 Checks: 22 - every node's (context, id) matches a real token in `tokens`; 23 - every node's `value` is non-empty; 24 - an action node's `related_node`, if set, resolves to another 25 action node's id in the same context; 26 - an agent or target node's `related_node` is set (not None) and 27 resolves to an action node's id in the same context. 28 """ 29 problems: List[str] = [] 30 31 token_ids = {(t.context, t.id) for t in tokens} 32 action_ids = {(n.context, n.id) for n in graph.nodes if n.role == "action"} 33 34 for node in graph.nodes: 35 if (node.context, node.id) not in token_ids: 36 problems.append( 37 f"node ({node.context!r}, {node.id!r}, role={node.role!r}) " 38 "does not match any token in the input token list" 39 ) 40 if not node.value: 41 problems.append( 42 f"node ({node.context!r}, {node.id!r}) has an empty value" 43 ) 44 45 if node.role == "action": 46 if node.related_node is not None and (node.context, node.related_node) not in action_ids: 47 problems.append( 48 f"action node ({node.context!r}, {node.id!r}) has related_node " 49 f"{node.related_node!r}, which is not the id of any action node " 50 "in this context" 51 ) 52 else: # agent or target 53 if node.related_node is None: 54 problems.append( 55 f"{node.role} node ({node.context!r}, {node.id!r}) has no " 56 "related_node -- every agent/target node must relate to an " 57 "action node" 58 ) 59 elif (node.context, node.related_node) not in action_ids: 60 problems.append( 61 f"{node.role} node ({node.context!r}, {node.id!r}) has related_node " 62 f"{node.related_node!r}, which is not the id of any action node " 63 "in this context" 64 ) 65 66 return problems
Return a list of human-readable problem descriptions found in
graph, given the tokens it was built from. An empty list means no
referential problems were found (not that the analysis is correct).
Checks:
- every node's (context, id) matches a real token in
tokens;- every node's
valueis non-empty;- an action node's
related_node, if set, resolves to another action node's id in the same context;- an agent or target node's
related_nodeis set (not None) and resolves to an action node's id in the same context.
169def serialize_nodes(nodes: List[AATNode]) -> str: 170 """Render `nodes` as one '#!aatnodes' block, pipe-delimited, and 171 return it as a string. Does not check for '|' inside any field's own 172 value -- there is no escaping mechanism, so avoid the delimiter 173 character in `value` if you plan to round-trip through this format.""" 174 lines = [AATNODES_LABEL, _AATNODES_HEADER] 175 for node in nodes: 176 related = node.related_node or "" 177 lines.append(f"{node.context}|{node.id}|{node.value}|{node.role}|{related}") 178 return "\n".join(lines) + "\n"
Render nodes as one '#!aatnodes' block, pipe-delimited, and
return it as a string. Does not check for '|' inside any field's own
value -- there is no escaping mechanism, so avoid the delimiter
character in value if you plan to round-trip through this format.
181def write_nodes(nodes: List[AATNode], path: str) -> None: 182 """Write serialize_nodes(nodes) to `path`.""" 183 with open(path, "w", encoding="utf-8") as f: 184 f.write(serialize_nodes(nodes))
Write serialize_nodes(nodes) to path.
187def read_nodes(path: str) -> List[AATNode]: 188 """Read every '#!aatnodes' block in `path` and return their rows, 189 concatenated in file order, as a list of AATNode -- other block 190 types in the same file (e.g. a '#!tokens' block) are ignored, see 191 _read_blocks(). 192 193 Raises ValueError (via _read_blocks()) for a malformed file -- see 194 that function's own docstring for the exact cases. Also raises 195 ValueError (not returning an empty list) if the file has no 196 '#!aatnodes' block at all, so a caller can't mistake "wrong file" for 197 "file with zero nodes". 198 """ 199 rows, seen = _read_blocks(path, AATNODES_LABEL, _AATNODES_HEADER) 200 if not seen: 201 raise ValueError(f"file has no {AATNODES_LABEL!r} block") 202 203 nodes: List[AATNode] = [] 204 for context, id_, value, role, related in rows: 205 nodes.append( 206 AATNode( 207 context=context, 208 id=id_, 209 value=value, 210 role=role, 211 related_node=related or None, 212 ) 213 ) 214 return nodes
Read every '#!aatnodes' block in path and return their rows,
concatenated in file order, as a list of AATNode -- other block
types in the same file (e.g. a '#!tokens' block) are ignored, see
_read_blocks().
Raises ValueError (via _read_blocks()) for a malformed file -- see that function's own docstring for the exact cases. Also raises ValueError (not returning an empty list) if the file has no '#!aatnodes' block at all, so a caller can't mistake "wrong file" for "file with zero nodes".
217def read_graph(path: str) -> AATGraph: 218 """Convenience wrapper: read_nodes(path) wrapped as an AATGraph.""" 219 return AATGraph(nodes=read_nodes(path))
Convenience wrapper: read_nodes(path) wrapped as an AATGraph.
222def serialize_tokens(tokens: List[CitableToken]) -> str: 223 """Render `tokens` as one '#!tokens' block, pipe-delimited, and 224 return it as a string, in `tokens`' own order -- unlike 225 serialize_nodes(), row order here is meaningful (it's the only thing 226 that records reading order; a token's `id` alone doesn't, especially 227 for a composite sentence-spanning id like "1.14.t3" -- see this 228 module's own top docstring). Same no-escaping caveat as 229 serialize_nodes(): avoid '|' in `value` if you plan to round-trip 230 through this format.""" 231 lines = [TOKENS_LABEL, _TOKENS_HEADER] 232 for token in tokens: 233 lines.append(f"{token.context}|{token.id}|{token.value}") 234 return "\n".join(lines) + "\n"
Render tokens as one '#!tokens' block, pipe-delimited, and
return it as a string, in tokens' own order -- unlike
serialize_nodes(), row order here is meaningful (it's the only thing
that records reading order; a token's id alone doesn't, especially
for a composite sentence-spanning id like "1.14.t3" -- see this
module's own top docstring). Same no-escaping caveat as
serialize_nodes(): avoid '|' in value if you plan to round-trip
through this format.
237def write_tokens(tokens: List[CitableToken], path: str) -> None: 238 """Write serialize_tokens(tokens) to `path`.""" 239 with open(path, "w", encoding="utf-8") as f: 240 f.write(serialize_tokens(tokens))
Write serialize_tokens(tokens) to path.
243def read_tokens(path: str) -> List[CitableToken]: 244 """Read every '#!tokens' block in `path` and return their rows, 245 concatenated in file order (which is also reading order -- see 246 serialize_tokens()'s own docstring), as a list of CitableToken -- 247 other block types in the same file (e.g. a '#!aatnodes' block) are 248 ignored, see _read_blocks(). 249 250 Raises ValueError (via _read_blocks()) for a malformed file. Also 251 raises ValueError if the file has no '#!tokens' block at all, same 252 reasoning as read_nodes().""" 253 rows, seen = _read_blocks(path, TOKENS_LABEL, _TOKENS_HEADER) 254 if not seen: 255 raise ValueError(f"file has no {TOKENS_LABEL!r} block") 256 return [CitableToken(context=context, id=id_, value=value) for context, id_, value in rows]
Read every '#!tokens' block in path and return their rows,
concatenated in file order (which is also reading order -- see
serialize_tokens()'s own docstring), as a list of CitableToken --
other block types in the same file (e.g. a '#!aatnodes' block) are
ignored, see _read_blocks().
Raises ValueError (via _read_blocks()) for a malformed file. Also raises ValueError if the file has no '#!tokens' block at all, same reasoning as read_nodes().
259def serialize_analysis(tokens: List[CitableToken], graph: AATGraph) -> str: 260 """Render a complete, re-displayable analysis as one string: a 261 '#!tokens' block (the complete, already-tokenized input every node 262 in `graph` refers back to -- no re-tokenization needed to reload it, 263 see this module's own top docstring) followed by a '#!aatnodes' 264 block (the AAT graph an earlier, LM-dependent analysis step 265 produced). write_analysis() is a thin wrapper that writes this same 266 string to a file; call this directly instead when the text itself is 267 what's wanted -- e.g. to hand to a UI that writes the file somewhere 268 else (a chosen directory, a download, ...), or to embed the analysis 269 in something larger without touching disk here at all. read_analysis() 270 is the matching reader for a file written either way -- see that 271 function's own docstring for the intended round trip.""" 272 return serialize_tokens(tokens) + "\n" + serialize_nodes(graph.nodes)
Render a complete, re-displayable analysis as one string: a
'#!tokens' block (the complete, already-tokenized input every node
in graph refers back to -- no re-tokenization needed to reload it,
see this module's own top docstring) followed by a '#!aatnodes'
block (the AAT graph an earlier, LM-dependent analysis step
produced). write_analysis() is a thin wrapper that writes this same
string to a file; call this directly instead when the text itself is
what's wanted -- e.g. to hand to a UI that writes the file somewhere
else (a chosen directory, a download, ...), or to embed the analysis
in something larger without touching disk here at all. read_analysis()
is the matching reader for a file written either way -- see that
function's own docstring for the intended round trip.
275def write_analysis(tokens: List[CitableToken], graph: AATGraph, path: str) -> None: 276 """Write serialize_analysis(tokens, graph) to `path`. See that 277 function's own docstring for exactly what gets written, and 278 read_analysis() for the matching reader.""" 279 with open(path, "w", encoding="utf-8") as f: 280 f.write(serialize_analysis(tokens, graph))
Write serialize_analysis(tokens, graph) to path. See that
function's own docstring for exactly what gets written, and
read_analysis() for the matching reader.
283def read_analysis(path: str) -> Tuple[List[CitableToken], AATGraph]: 284 """Read a file written by write_analysis(): both its '#!tokens' 285 block (as a list of CitableToken, in reading order) and its 286 '#!aatnodes' block (as an AATGraph). Raises ValueError if either 287 block is missing -- see read_tokens()/read_nodes(). 288 289 Unlike the earlier '#!passages'-based format this replaced, the 290 returned `tokens` are already exactly what the original analysis 291 used -- e.g. straight into aat.core.tokens_to_html(tokens, 292 graph=graph) (also re-exported as aat.english.tokens_to_html) -- no 293 re-tokenization step, and so no dependency on 294 aat.english.tokenize() (or aat.english.tokenize_corpus_by_sentence() 295 for a sentence-spanning file) at all. This whole round trip still 296 needs no LM access at any point; it just no longer needs to re-run 297 any deterministic code either. 298 """ 299 tokens = read_tokens(path) 300 graph = read_graph(path) 301 return tokens, graph
Read a file written by write_analysis(): both its '#!tokens' block (as a list of CitableToken, in reading order) and its '#!aatnodes' block (as an AATGraph). Raises ValueError if either block is missing -- see read_tokens()/read_nodes().
Unlike the earlier '#!passages'-based format this replaced, the
returned tokens are already exactly what the original analysis
used -- e.g. straight into aat.core.tokens_to_html(tokens,
graph=graph) (also re-exported as aat.english.tokens_to_html) -- no
re-tokenization step, and so no dependency on
aat.english.tokenize (or aat.english.tokenize_corpus_by_sentence()
for a sentence-spanning file) at all. This whole round trip still
needs no LM access at any point; it just no longer needs to re-run
any deterministic code either.
119def tokens_to_html(tokens: List[CitableToken], graph: Optional[AATGraph] = None) -> str: 120 """Render `tokens` as one continuous HTML string: ordinary words get 121 a space before them, punctuation attaches per this module's own 122 docstring (left-joining by default; opening brackets and the first 123 of a quote pair right-join instead). 124 125 If `graph` is given, every token whose (context, id) matches a node 126 in `graph` is highlighted: background/text colored the same way 127 that node is colored in `aat.core.mermaid.graph_to_mermaid()`'s 128 diagram (via `aat.core.coloring.assign_action_colors()`), with a 129 role-specific border -- a box around an `action` token, a rounded 130 box around an `agent` token, an underline under a `target` token. 131 Omit `graph` (or pass `None`) for plain, unhighlighted text. 132 133 Every token's text is HTML-escaped before being emitted, span or 134 not -- real passage text can contain a literal `<`, `>`, `&`, or 135 quote character, which would otherwise be indistinguishable from 136 markup to anything that re-parses this output. 137 """ 138 color_of_node: Dict[Tuple[str, str], Tuple[str, str, str]] = {} 139 role_of_node: Dict[Tuple[str, str], str] = {} 140 if graph is not None: 141 color_of_node, _warnings = assign_action_colors(graph) 142 role_of_node = {(n.context, n.id): n.role for n in graph.nodes} 143 144 quote_counts: Dict[str, int] = {} 145 pieces: List[str] = [] 146 previous_class: Optional[str] = None 147 148 for token in tokens: 149 cls = _classify(token, quote_counts) 150 rendered = html.escape(token.value) 151 152 key = (token.context, token.id) 153 color = color_of_node.get(key) 154 if color is not None: 155 fill, stroke, text_color = color 156 role = role_of_node.get(key, "") 157 role_style = _ROLE_STYLE.get(role, "").format(stroke=stroke) 158 rendered = ( 159 f'<span style="background-color: {fill}; color: {text_color}; {role_style}">' 160 f"{rendered}</span>" 161 ) 162 163 if not pieces: 164 # Nothing precedes the first token -- never prepend a space. 165 pieces.append(rendered) 166 elif cls == _LEFT: 167 pieces.append(rendered) 168 elif cls == _RIGHT: 169 pieces.append(" " + rendered) 170 else: # _NORMAL 171 if previous_class == _RIGHT: 172 pieces.append(rendered) 173 else: 174 pieces.append(" " + rendered) 175 176 previous_class = cls 177 178 return "".join(pieces)
Render tokens as one continuous HTML string: ordinary words get
a space before them, punctuation attaches per this module's own
docstring (left-joining by default; opening brackets and the first
of a quote pair right-join instead).
If graph is given, every token whose (context, id) matches a node
in graph is highlighted: background/text colored the same way
that node is colored in aat.core.mermaid.graph_to_mermaid()'s
diagram (via aat.core.coloring.assign_action_colors()), with a
role-specific border -- a box around an action token, a rounded
box around an agent token, an underline under a target token.
Omit graph (or pass None) for plain, unhighlighted text.
Every token's text is HTML-escaped before being emitted, span or
not -- real passage text can contain a literal <, >, &, or
quote character, which would otherwise be indistinguishable from
markup to anything that re-parses this output.
76def graph_to_mermaid( 77 graph: AATGraph, 78 orientation: str = "BT", 79 color_by_action: bool = True, 80) -> Tuple[str, List[str]]: 81 """Build a Mermaid `graph` diagram from an AATGraph. 82 83 `orientation` is Mermaid's own flowchart direction code -- `BT` 84 (bottom-to-top, the default here), `TB`/`TD` (top-down -- synonyms), 85 `LR`, or `RL` -- used verbatim (uppercased) in the diagram's opening 86 line (`graph BT`, etc.). Validated by aat.core.orientation (shared 87 with graph_to_dot()); anything else raises `ValueError` naming the 88 valid options, rather than silently producing invalid Mermaid syntax. 89 See https://mermaid.js.org/syntax/flowchart.html#direction. 90 91 `color_by_action` (default True) -- see this module's own docstring. 92 Pass False for a plain, uncolored diagram. 93 94 Returns (diagram_text, warnings). A node whose `related_node` doesn't 95 resolve to any node actually present in `graph` (same context) is 96 still drawn, but its edge is skipped and reported as a warning -- 97 normally a sign `graph` failed aat.core.validate.validate() upstream, 98 worth checking there first. If `color_by_action` is True and the graph 99 has more distinct actions than the palette has colors (currently 8), 100 one warning notes that colors repeat. 101 """ 102 orientation = normalize_orientation(orientation) 103 104 by_key: Dict[Tuple[str, str], AATNode] = {_node_key(n): n for n in graph.nodes} 105 106 lines = [f"graph {orientation}"] 107 for node in graph.nodes: 108 open_b, close_b = _ROLE_BRACKETS.get(node.role, ("[", "]")) 109 lines.append(f' {node.id}{open_b}"{_escape_label(node.value)}"{close_b}') 110 111 warnings: List[str] = [] 112 for node in graph.nodes: 113 if node.related_node is None: 114 continue 115 target_key = (node.context, node.related_node) 116 if target_key not in by_key: 117 warnings.append( 118 f"skipped edge {node.id} -[{node.role}]-> {node.related_node}: " 119 "target is not a node in this graph" 120 ) 121 continue 122 edge_label = "dependent" if node.role == "action" else node.role 123 lines.append(f" {node.id} -->|{edge_label}| {node.related_node}") 124 125 if color_by_action: 126 color_of_node, color_warnings = assign_action_colors(graph) 127 warnings.extend(color_warnings) 128 129 if color_of_node: 130 lines.append("") 131 132 # Group by color VALUE, not by action -- two actions that 133 # cycle to the same palette color (more actions than the 134 # palette has slots) share a single classDef/class pair here 135 # rather than emitting two redundant classes with identical 136 # colors. 137 colors_in_order: List[ColorTriple] = [] 138 seen_colors = set() 139 for node in graph.nodes: 140 color = color_of_node.get(_node_key(node)) 141 if color is not None and color not in seen_colors: 142 seen_colors.add(color) 143 colors_in_order.append(color) 144 145 class_name_of_color: Dict[ColorTriple, str] = { 146 color: f"c{i}" for i, color in enumerate(colors_in_order) 147 } 148 for color in colors_in_order: 149 fill, stroke, text = color 150 lines.append( 151 f" classDef {class_name_of_color[color]} " 152 f"fill:{fill},stroke:{stroke},color:{text};" 153 ) 154 155 nodes_by_class: Dict[str, List[str]] = {} 156 for node in graph.nodes: 157 color = color_of_node.get(_node_key(node)) 158 if color is None: 159 continue 160 class_name = class_name_of_color[color] 161 nodes_by_class.setdefault(class_name, []).append(node.id) 162 163 for class_name, node_ids in nodes_by_class.items(): 164 lines.append(f" class {','.join(node_ids)} {class_name};") 165 166 return "\n".join(lines), warnings
Build a Mermaid graph diagram from an AATGraph.
orientation is Mermaid's own flowchart direction code -- BT
(bottom-to-top, the default here), TB/TD (top-down -- synonyms),
LR, or RL -- used verbatim (uppercased) in the diagram's opening
line (graph BT, etc.). Validated by aat.core.orientation (shared
with graph_to_dot()); anything else raises ValueError naming the
valid options, rather than silently producing invalid Mermaid syntax.
See https://mermaid.js.org/syntax/flowchart.html#direction.
color_by_action (default True) -- see this module's own docstring.
Pass False for a plain, uncolored diagram.
Returns (diagram_text, warnings). A node whose related_node doesn't
resolve to any node actually present in graph (same context) is
still drawn, but its edge is skipped and reported as a warning --
normally a sign graph failed aat.core.validate.validate() upstream,
worth checking there first. If color_by_action is True and the graph
has more distinct actions than the palette has colors (currently 8),
one warning notes that colors repeat.
169def save_mermaid( 170 graph: AATGraph, 171 path: str, 172 orientation: str = "BT", 173 color_by_action: bool = True, 174) -> List[str]: 175 """Write the diagram to `path` (e.g. 'analysis.mmd') and return any 176 warnings from graph_to_mermaid(). `orientation` is validated the same 177 way -- see graph_to_mermaid()'s own docstring.""" 178 diagram, warnings = graph_to_mermaid(graph, orientation=orientation, color_by_action=color_by_action) 179 with open(path, "w", encoding="utf-8") as f: 180 f.write(diagram + "\n") 181 return warnings
Write the diagram to path (e.g. 'analysis.mmd') and return any
warnings from graph_to_mermaid(). orientation is validated the same
way -- see graph_to_mermaid()'s own docstring.
82def graph_to_dot( 83 graph: AATGraph, 84 orientation: str = "BT", 85 color_by_action: bool = True, 86) -> Tuple[str, List[str]]: 87 """Build a Graphviz DOT digraph from an AATGraph -- see this module's 88 own docstring for the node-shape/edge/coloring mapping, which mirrors 89 aat.core.mermaid.graph_to_mermaid() exactly except for the target 90 syntax. 91 92 `orientation` is validated exactly like graph_to_mermaid()'s (see 93 aat.core.orientation) -- `BT` (bottom-to-top, the default here), 94 `TB`/`TD` (top-down -- synonyms), `LR`, or `RL`. Written out as 95 Graphviz's own `rankdir` graph attribute; since `rankdir` has no 96 "TD" of its own, "TD" is mapped to "TB" here (same direction, just 97 Graphviz's own name for it) -- every other value is used verbatim. 98 Anything outside that vocabulary raises `ValueError` naming the 99 valid options, rather than silently producing invalid DOT syntax. 100 101 `color_by_action` (default True) -- see this module's own docstring. 102 Pass False for a plain, uncolored digraph. 103 104 Returns (dot_text, warnings) -- same warning cases as 105 graph_to_mermaid(): a node whose `related_node` doesn't resolve to 106 another node actually present in `graph` (same context) is still 107 drawn, but its edge is skipped and reported as a warning; and, if 108 `color_by_action` is True and the graph has more distinct actions 109 than the palette has colors (currently 8), one warning notes that 110 colors repeat. 111 """ 112 orientation = normalize_orientation(orientation) 113 rankdir = "TB" if orientation == "TD" else orientation 114 115 by_key: Dict[Tuple[str, str], AATNode] = {_node_key(n): n for n in graph.nodes} 116 117 color_of_node: Dict[Tuple[str, str], ColorTriple] = {} 118 warnings: List[str] = [] 119 if color_by_action: 120 color_of_node, color_warnings = assign_action_colors(graph) 121 warnings.extend(color_warnings) 122 123 lines = ["digraph aat {", f" rankdir={rankdir};"] 124 125 for node in graph.nodes: 126 attrs = [f"shape={_ROLE_SHAPE.get(node.role, 'box')}"] 127 styles = list(_ROLE_EXTRA_STYLES.get(node.role, [])) 128 129 color = color_of_node.get(_node_key(node)) 130 if color is not None: 131 fill, stroke, text = color 132 styles.append("filled") 133 attrs.append(f'fillcolor="{fill}"') 134 attrs.append(f'color="{stroke}"') 135 attrs.append(f'fontcolor="{text}"') 136 137 if styles: 138 attrs.append(f'style="{",".join(styles)}"') 139 attrs.append(f'label="{_escape_label(node.value)}"') 140 141 lines.append(f' {node.id} [{", ".join(attrs)}];') 142 143 for node in graph.nodes: 144 if node.related_node is None: 145 continue 146 target_key = (node.context, node.related_node) 147 if target_key not in by_key: 148 warnings.append( 149 f"skipped edge {node.id} -[{node.role}]-> {node.related_node}: " 150 "target is not a node in this graph" 151 ) 152 continue 153 edge_label = "dependent" if node.role == "action" else node.role 154 lines.append(f' {node.id} -> {node.related_node} [label="{edge_label}"];') 155 156 lines.append("}") 157 return "\n".join(lines), warnings
Build a Graphviz DOT digraph from an AATGraph -- see this module's own docstring for the node-shape/edge/coloring mapping, which mirrors aat.core.mermaid.graph_to_mermaid() exactly except for the target syntax.
orientation is validated exactly like graph_to_mermaid()'s (see
aat.core.orientation) -- BT (bottom-to-top, the default here),
TB/TD (top-down -- synonyms), LR, or RL. Written out as
Graphviz's own rankdir graph attribute; since rankdir has no
"TD" of its own, "TD" is mapped to "TB" here (same direction, just
Graphviz's own name for it) -- every other value is used verbatim.
Anything outside that vocabulary raises ValueError naming the
valid options, rather than silently producing invalid DOT syntax.
color_by_action (default True) -- see this module's own docstring.
Pass False for a plain, uncolored digraph.
Returns (dot_text, warnings) -- same warning cases as
graph_to_mermaid(): a node whose related_node doesn't resolve to
another node actually present in graph (same context) is still
drawn, but its edge is skipped and reported as a warning; and, if
color_by_action is True and the graph has more distinct actions
than the palette has colors (currently 8), one warning notes that
colors repeat.
160def save_dot( 161 graph: AATGraph, 162 path: str, 163 orientation: str = "BT", 164 color_by_action: bool = True, 165) -> List[str]: 166 """Write the digraph to `path` (e.g. 'analysis.dot') and return any 167 warnings from graph_to_dot(). `orientation` is validated the same 168 way -- see graph_to_dot()'s own docstring.""" 169 dot_text, warnings = graph_to_dot(graph, orientation=orientation, color_by_action=color_by_action) 170 with open(path, "w", encoding="utf-8") as f: 171 f.write(dot_text + "\n") 172 return warnings
Write the digraph to path (e.g. 'analysis.dot') and return any
warnings from graph_to_dot(). orientation is validated the same
way -- see graph_to_dot()'s own docstring.
48def assign_action_colors(graph: AATGraph) -> Tuple[Dict[NodeKey, ColorTriple], List[str]]: 49 """Return (color_of_node, warnings). 50 51 `color_of_node` maps every node's (context, id) to a (fill, stroke, 52 text) hex triple, for every node that clusters with some action: an 53 action node itself (dependent or not -- see this module's own 54 docstring), or an agent/target node whose `related_node` resolves to 55 an action node in the same context. A node with no resolvable cluster 56 (e.g. an agent/target with a broken related_node -- shouldn't happen 57 in a graph that's passed aat.core.validate.validate()) is simply 58 absent from the mapping. 59 60 Colors are assigned to actions in the order they first appear in 61 `graph.nodes`, cycling through the palette (currently 8 colors) if 62 there are more distinct actions than that -- in which case two 63 distinct actions (and their own agent/target nodes) end up sharing the 64 identical color. `warnings` has one entry naming this when it happens; 65 otherwise it's empty. 66 """ 67 actions = [n for n in graph.nodes if n.role == "action"] 68 69 warnings: List[str] = [] 70 if len(actions) > len(_ACTION_COLOR_PALETTE): 71 warnings.append( 72 f"{len(actions)} actions but only {len(_ACTION_COLOR_PALETTE)} palette " 73 "colors -- colors repeat and some distinct actions will share a color" 74 ) 75 76 action_index: Dict[NodeKey, int] = {_node_key(a): i for i, a in enumerate(actions)} 77 78 by_key: Dict[NodeKey, AATNode] = {_node_key(n): n for n in graph.nodes} 79 cluster_of_node: Dict[NodeKey, NodeKey] = {} 80 for node in graph.nodes: 81 key = _node_key(node) 82 if node.role == "action": 83 cluster_of_node[key] = key 84 elif node.related_node is not None: 85 related_key = (node.context, node.related_node) 86 if related_key in by_key: 87 cluster_of_node[key] = related_key 88 89 color_of_node: Dict[NodeKey, ColorTriple] = {} 90 for node in graph.nodes: 91 key = _node_key(node) 92 cluster_key = cluster_of_node.get(key) 93 if cluster_key is None or cluster_key not in action_index: 94 continue 95 color_of_node[key] = _ACTION_COLOR_PALETTE[action_index[cluster_key] % len(_ACTION_COLOR_PALETTE)] 96 97 return color_of_node, warnings
Return (color_of_node, warnings).
color_of_node maps every node's (context, id) to a (fill, stroke,
text) hex triple, for every node that clusters with some action: an
action node itself (dependent or not -- see this module's own
docstring), or an agent/target node whose related_node resolves to
an action node in the same context. A node with no resolvable cluster
(e.g. an agent/target with a broken related_node -- shouldn't happen
in a graph that's passed aat.core.validate.validate()) is simply
absent from the mapping.
Colors are assigned to actions in the order they first appear in
graph.nodes, cycling through the palette (currently 8 colors) if
there are more distinct actions than that -- in which case two
distinct actions (and their own agent/target nodes) end up sharing the
identical color. warnings has one entry naming this when it happens;
otherwise it's empty.
39def parse_cex_ctsdata(text: str, delimiter: str = "#") -> List[CitedPassage]: 40 """Parse every '#!ctsdata' block in `text` (a CEX file's own full 41 contents, already read into a string) and return its rows, 42 concatenated in file order, as a list of CitedPassage -- `context` is 43 each row's own CTS URN, `text` is its citable text. Every other CEX 44 block type is ignored entirely, whether it appears before, between, 45 or after the '#!ctsdata' block(s); multiple '#!ctsdata' blocks in one 46 file are concatenated, in file order, same as 47 aat.core.serialization's own multi-block handling. 48 49 Raises ValueError, naming the offending line, for a '#!ctsdata' row 50 that has no `delimiter` in it at all -- most often a sign `delimiter` 51 is wrong for this particular file. Raises ValueError (not returning 52 an empty list) if the file has no '#!ctsdata' block at all, so a 53 caller can't mistake "wrong file" for "file with zero passages" -- 54 same reasoning as aat.core.serialization.read_nodes()/read_tokens(). 55 """ 56 passages: List[CitedPassage] = [] 57 in_ctsdata = False 58 seen = False 59 60 for line_no, raw_line in enumerate(text.splitlines(), start=1): 61 line = raw_line.strip("\r\n") 62 if line == "" or line.startswith("//"): 63 continue 64 65 if line.startswith("#!"): 66 in_ctsdata = line == CTSDATA_LABEL 67 if in_ctsdata: 68 seen = True 69 continue 70 71 if not in_ctsdata: 72 continue 73 74 urn, sep, passage_text = line.partition(delimiter) 75 if not sep: 76 raise ValueError( 77 f"line {line_no}: no {delimiter!r} delimiter found in a " 78 f"{CTSDATA_LABEL!r} row -- wrong --delimiter for this file? {line!r}" 79 ) 80 passages.append(CitedPassage(context=urn, text=passage_text)) 81 82 if not seen: 83 raise ValueError(f"file has no {CTSDATA_LABEL!r} block") 84 85 return passages
Parse every '#!ctsdata' block in text (a CEX file's own full
contents, already read into a string) and return its rows,
concatenated in file order, as a list of CitedPassage -- context is
each row's own CTS URN, text is its citable text. Every other CEX
block type is ignored entirely, whether it appears before, between,
or after the '#!ctsdata' block(s); multiple '#!ctsdata' blocks in one
file are concatenated, in file order, same as
aat.core.serialization's own multi-block handling.
Raises ValueError, naming the offending line, for a '#!ctsdata' row
that has no delimiter in it at all -- most often a sign delimiter
is wrong for this particular file. Raises ValueError (not returning
an empty list) if the file has no '#!ctsdata' block at all, so a
caller can't mistake "wrong file" for "file with zero passages" --
same reasoning as aat.core.serialization.read_nodes()/read_tokens().
88def read_cex_passages(path: str, delimiter: str = "#") -> List[CitedPassage]: 89 """Read `path` as a CEX file and return parse_cex_ctsdata() of its 90 contents -- see that function's own docstring for the exact format 91 and error cases.""" 92 with open(path, "r", encoding="utf-8") as f: 93 text = f.read() 94 return parse_cex_ctsdata(text, delimiter=delimiter)
Read path as a CEX file and return parse_cex_ctsdata() of its
contents -- see that function's own docstring for the exact format
and error cases.