第 9 章
上下文与决策智能
场景还原
季度审计的前一天晚上,合规审计员小李要回答一个问题:三个月前那笔五百万授信为什么批了。系统里能查到的只有三样东西:大模型生成的回答文本、一个 0.94 的置信度、以及一个「approved」结果。当时适用的是哪一版授信政策?政策在那之后改过没有?是谁在 Slack 里口头批准的例外?这些问题的答案散落在聊天记录、邮件和某个同事的脑子里。小李花了两天拼出一条时间线,最后还是有两处对不上。问题在于,模型答完之后,依据、版本、批准过程全都没有被结构化留下来。小李要的是一次能经得起重算的证据链,一行回答文本不够。
这就是本章要解决的问题。前面八章把杂乱数据变成了一张可解释的知识图谱,但图谱里的节点是实体和关系,缺了「人做过什么决定」这个维度。企业里最需要被审计的恰好是这个维度。Semantica 的 context 模块是全仓库最大的模块,约 1.9 万行、17 个 Python 文件,它做的事可以压缩成一条五步流水线:记录、连边、查询、治理、审计。本章沿这五步走一遍,看一个决策如何从「一句话输出」变成图里可追溯的一等公民。
逐行精读
记录:让决策成为一等节点
先看决策的载体。Semantica 把决策定义成一个 dataclass,八个必填字段把「谁、在什么时候、基于什么推理、得出了什么结果」一次性钉死。
86@dataclass87class Decision:88 """Core decision data model with full context tracking."""89 90 decision_id: str91 category: str92 scenario: str93 reasoning: str94 outcome: str95 confidence: float96 timestamp: datetime97 decision_maker: str98 reasoning_embedding: Optional[List[float]] = None99 node2vec_embedding: Optional[List[float]] = None100 valid_from: Optional[str] = None101 valid_until: Optional[str] = None102 metadata: Dict[str, Any] = field(default_factory=dict)103 104 def __post_init__(self, auto_generate_id: bool = True):105 """Validate decision data."""106 if auto_generate_id and not self.decision_id: # Handle both None and empty string107 self.decision_id = str(uuid.uuid4())108 elif not self.decision_id and not auto_generate_id:109 raise ValueError("decision_id is required when auto_generate_id=False")110 if not 0 <= self.confidence <= 1:111 raise ValueError("Confidence must be between 0 and 1")112 113 def to_dict(self) -> Dict[str, Any]:114 """Convert decision to dictionary."""115 return {116 "decision_id": self.decision_id,117 "category": self.category,118 "scenario": self.scenario,119 "reasoning": self.reasoning,120 "outcome": self.outcome,121 "confidence": self.confidence,122 "timestamp": self.timestamp.isoformat(),123 "decision_maker": self.decision_maker,124 "reasoning_embedding": self.reasoning_embedding,125 "node2vec_embedding": self.node2vec_embedding,126 "valid_from": self.valid_from,127 "valid_until": self.valid_until,128 "metadata": self.metadata129 }130 131 @classmethod132 def from_dict(cls, data: Dict[str, Any]) -> "Decision":133 """Create decision from dictionary."""134 if isinstance(data.get("timestamp"), str):135 data["timestamp"] = datetime.fromisoformat(data["timestamp"])136 return cls(**data)三个细节值得注意。confidence 在 __post_init__ 里被约束在 0 到 1 之间,越界直接抛 ValueError;decision_id 为空时自动生成 UUID;valid_from 和 valid_until 两个字段让决策本身也带时态窗口。这意味着决策从出生的那一刻起就自带主键和置信度约束,后面所有环节都能引用它。
决策落进内存图时,外面再包一层 ContextNode,它才是图里的实际节点结构。
321@dataclass322class ContextNode:323 """Context graph node (Internal implementation)."""324325 node_id: str326 node_type: str327 content: str328 metadata: Dict[str, Any] = field(default_factory=dict)329 properties: Dict[str, Any] = field(default_factory=dict)330 valid_from: Optional[str] = None 331 valid_until: Optional[str] = None 332333 def is_active(self, at_time: Optional[datetime] = None) -> bool:334 """Return True if this node is active at the given time (defaults to now).335336 Both ``at_time`` and stored bounds are normalized to tz-naive UTC so that337 callers may pass either aware or naive datetimes without raising TypeError.338 """339 if self.valid_from is None and self.valid_until is None:340 return True341 now = at_time if at_time is not None else datetime.utcnow()342 if now.tzinfo is not None:343 now = now.astimezone(timezone.utc).replace(tzinfo=None)344 start = _parse_iso_dt(self.valid_from) if self.valid_from is not None else None345 end = _parse_iso_dt(self.valid_until) if self.valid_until is not None else None346 if start is not None and now < start:347 return False348 if end is not None and now > end:349 return False350 return True351352 def to_dict(self) -> Dict[str, Any]:353 """Convert to dictionary format."""354 props = self.properties.copy()355 props.update(self.metadata)356 props["content"] = self.content357 if self.valid_from is not None:358 props["valid_from"] = self.valid_from359 if self.valid_until is not None:360 props["valid_until"] = self.valid_until361 return {"id": self.node_id, "type": self.node_type, "properties": props}ContextNode 自己带 valid_from 和 valid_until,is_active 在给定时间点判断节点是否还在生效期。决策节点落到图里时,category、reasoning、outcome 这些字段都被塞进 properties,成为可查询的属性。
reasoning_embedding 和 node2vec_embedding 是两个预埋的向量钩子。前者存推理文本的语义嵌入,由 record_decision 里的 embedding_generator 现场生成;后者留给图结构的节点嵌入,供相似度检索时用。生成动作被延迟到调用方真的传入了嵌入生成器、且字段还是空的时候才发生,纯落库场景不付这个代价。metadata 是任意键值的兜底口袋,业务字段加不进去就往里塞。
连实体这一步同样在 record_decision 里完成,link_entities 在第 156 行定义。它给每个实体补一个 Entity 节点,再连一条 ABOUT 边,决策「关于」哪些客户、哪笔资产,在这一步落到图里。source_documents 参数一路透传到溯源层,把决策读过的原始文档也登记下来。
决策周围还有四个配角模型,都在 decision_models.py 里。PolicyException 记录一次策略例外,谁批的、批的时间、为什么批,九个字段钉死;ApprovalChain 记录系统外的人工审批,approval_method 被限制在 slack_dm、zoom_call、email、system 四种;Precedent 记录决策间的先例关系,relationship_type 只有 similar_scenario、same_policy、exception_precedent 三种;DecisionContext 存决策当时的实体快照和风险因子。它们共同回答审计的一句追问:这个决策当时面对的是什么、谁在旁边点了头。这些模型加一个 Decision,拼出决策记录层的完整数据面:决策本身、当时的上下文快照、套用的策略、开的例外、审批链、先例关系。
有了载体,再看写入入口。DecisionRecorder.record_decision 按固定顺序做四件事:生成嵌入、存节点、连实体、记溯源。
115 def record_decision(116 self,117 decision: Decision,118 entities: List[str],119 source_documents: List[str]120 ) -> str:121 """122 Record decision with full context.123 124 Args:125 decision: Decision object to record126 entities: List of entity IDs linked to this decision127 source_documents: List of source document identifiers128 129 Returns:130 Decision ID131 """132 try:133 # Generate embeddings if available134 if self.embedding_generator and not decision.reasoning_embedding:135 decision.reasoning_embedding = self.embedding_generator.generate(136 decision.reasoning137 )138 139 # Store decision in graph database140 self._store_decision_node(decision)141 142 # Link to entities143 self.link_entities(decision.decision_id, entities)144 145 # Track provenance146 if self.provenance_manager:147 self._track_decision_provenance(decision, source_documents)148 149 self.logger.info(f"Recorded decision: {decision.decision_id} | Actor: {decision.decision_maker} | Timestamp: {decision.timestamp} | Outcome: {decision.outcome} | Category: {decision.category}")150 return decision.decision_id151 152 except Exception as e:153 self.logger.exception("Failed to record decision")154 raise存节点这一步暴露了双后端策略。_store_decision_node 先判断后端类型:内存里的 ContextGraph 走 add_node,带 Cypher 的图数据库走 CREATE (d:Decision {...})。
469 def _store_decision_node(self, decision: Decision) -> None:470 """Store decision node in graph database."""471 metadata = decision.metadata.copy() if decision.metadata else {}472 metadata.update({473 "category": decision.category,474 "scenario": decision.scenario,475 "reasoning": decision.reasoning,476 "outcome": decision.outcome,477 "confidence": decision.confidence,478 "timestamp": decision.timestamp.isoformat() if decision.timestamp else None,479 "decision_maker": decision.decision_maker,480 "reasoning_embedding": decision.reasoning_embedding,481 "node2vec_embedding": decision.node2vec_embedding482 })483 484 if type(self.graph_store) is ContextGraph:485 self.graph_store.add_node(486 node_id=decision.decision_id,487 node_type="Decision",488 **metadata489 )490 return491492 query = """493 CREATE (d:Decision {494 decision_id: $decision_id,495 category: $category,496 scenario: $scenario,497 reasoning: $reasoning,498 outcome: $outcome,499 confidence: $confidence,500 timestamp: $timestamp,501 decision_maker: $decision_maker,502 reasoning_embedding: $reasoning_embedding,503 node2vec_embedding: $node2vec_embedding,504 metadata: $metadata505 })506 """507 self.graph_store.execute_query(query, {508 "decision_id": decision.decision_id,509 "category": decision.category,510 "scenario": decision.scenario,511 "reasoning": decision.reasoning,512 "outcome": decision.outcome,513 "confidence": decision.confidence,514 "timestamp": decision.timestamp,515 "decision_maker": decision.decision_maker,516 "reasoning_embedding": decision.reasoning_embedding,517 "node2vec_embedding": decision.node2vec_embedding,518 "metadata": decision.metadata519 })两套写入的字段几乎一致,差别只在时间戳处理:内存分支里 timestamp 已经归一化成 ISO 字符串存进 properties,Cypher 分支把原样的 decision.timestamp 直接交给后端。metadata 在 Cypher 分支是一个单独的 map 字段,在内存分支里则和八个业务字段一起展开进 properties。
最后一条动作是溯源。只要构造时传入了 provenance_manager,决策就被同时登记为一个实体和一次活动,喂给上一章讲过的 W3C PROV 溯源层。
587 def _track_decision_provenance(588 self,589 decision: Decision,590 source_documents: List[str]591 ) -> None:592 """Track decision provenance using ProvenanceManager."""593 if not self.provenance_manager:594 return595 596 try:597 # Track decision as entity598 self.provenance_manager.track_entity(599 entity_id=decision.decision_id,600 entity_type="decision",601 activity_id="decision_making_process",602 agent_id=decision.decision_maker,603 source_documents=source_documents,604 confidence=decision.confidence605 )606 607 # Track decision-making activity608 self.provenance_manager.track_activity(609 activity_id=f"decision_{decision.decision_id}",610 activity_type="decision_making",611 agent_id=decision.decision_maker,612 used_entities=[decision.decision_id],613 started_at=decision.timestamp,614 ended_at=decision.timestamp615 )616 617 except Exception as e:618 self.logger.exception("Failed to track provenance")记录阶段还有一件事:把决策和它适用的策略版本挂上钩。apply_policies 在第 190 行定义,它对每个策略引用解析出唯一一个版本,显式版本优先,否则取最新版,再连一条 APPLIED_POLICY 边,把版本号写进边的属性。
226 query = """227 MATCH (d:Decision {decision_id: $decision_id})228 MATCH (p:Policy {policy_id: $policy_id})229 WHERE $policy_version IS NULL OR p.version = $policy_version230 WITH d, p231 ORDER BY p.updated_at DESC, p.version DESC232 LIMIT 1233 MERGE (d)-[r:APPLIED_POLICY]->(p)234 SET r.policy_id = $policy_id,235 r.policy_version = p.version,236 d.applied_at = timestamp()237 RETURN p.policy_id as policy_id, p.version as version238 """ORDER BY p.updated_at DESC, p.version DESC 配 LIMIT 1 保证同 ID 多版本时只命中一个,MERGE 保证这条边幂等。这一步让「决策当时适用哪版策略」成为可查询的事实。
连边:把决策链进因果网
记录只是把决策写进图。决策的价值在于它和别的决策、别的实体之间有线。ContextGraph.add_causal_relationship 是显式连边的入口,它先规范化关系类型,再校验两端节点确实存在且确实是决策节点。
2751 def add_causal_relationship(2752 self,2753 source_decision_id: str,2754 target_decision_id: str,2755 relationship_type: str2756 ) -> None:2757 """2758 Add causal relationship between decisions.2759 2760 Args:2761 source_decision_id: Source decision ID2762 target_decision_id: Target decision ID2763 relationship_type: Type of relationship (CAUSED, INFLUENCED, PRECEDENT_FOR)2764 """2765 # Normalize so callers may use either vocabulary's spelling2766 # ("causes" from CausalChainAnalyzer, or "CAUSED" from this module's2767 # canonical constant); the stored form is always canonical. Invalid2768 # inputs keep raising ValueError rather than AttributeError.2769 if not isinstance(relationship_type, str):2770 raise ValueError(f"Relationship type must be one of: {_CAUSAL_EDGE_TYPES}")2771 relationship_type = _CAUSAL_EDGE_ALIASES.get(relationship_type.strip().upper())2772 if relationship_type is None:2773 raise ValueError(f"Relationship type must be one of: {_CAUSAL_EDGE_TYPES}")2774 2775 # Check if decisions exist - if not, skip adding relationship2776 if source_decision_id not in self.nodes or target_decision_id not in self.nodes:2777 return2778 2779 # Check if nodes are decision nodes - if not, skip adding relationship2780 source_node = self.nodes[source_decision_id]2781 target_node = self.nodes[target_decision_id]2782 if (not hasattr(source_node, 'node_type') or not isinstance(source_node.node_type, str) or2783 not hasattr(target_node, 'node_type') or not isinstance(target_node.node_type, str) or2784 source_node.node_type.lower() != "decision" or 2785 target_node.node_type.lower() != "decision"):2786 return2787 2788 edge = ContextEdge(2789 source_id=source_decision_id,2790 target_id=target_decision_id,2791 edge_type=relationship_type,2792 weight=1.0,2793 metadata={"recorded_at": datetime.utcnow().isoformat()},2794 )2795 self._add_internal_edge(edge)边在写入前要算一个稳定的 ID。_default_edge_id 在 context_graph.py 第 276 行定义,它把源、目标、类型、权重、时间窗、metadata 全部序列化成一个 JSON,再用 uuid5 命名空间哈希出 ID。同一对节点、同样属性、同样时间窗的边会得到同一个 ID,等于给边也做了幂等主键;recorded_at 这种每次不同的字段不在 ID 里,所以重复记录的边会各自独立存在,这在边界条件里会体现。
连完边之后怎么读回因果链,是 trace_decision_causality 的职责。它先守一道门:决策必须来自 record_decision 写入的内部字典 _decisions,然后反向建立显式因果边的索引,再递归回溯。
3596 if not hasattr(self, '_decisions') or decision_id not in self._decisions:3597 raise ValueError(f"Decision {decision_id} not found")35983599 try:3600 # Use graph traversal to find causal relationships3601 causal_chain = []3602 chain_limit = float("inf") if max_chains is None else max_chains3603 truncated = False36043605 # Reverse index of explicit causal edges, built once per call so the3606 # traversal does not rescan the edge list at every visited node.3607 # Edges may reference decision nodes that were never recorded through3608 # record_decision() (e.g. a graph restored via from_dict), so only3609 # causes with a known decision record are kept.3610 incoming_causal_edges = defaultdict(list)3611 # The index is keyed by the raw edge_type string ("causes" and3612 # "CAUSED" are separate keys), so filter by normalized type.3613 for edge_type, edges in self.edge_type_index.items():3614 if edge_type.upper() not in _CAUSAL_TRAVERSAL_TYPES:3615 continue3616 for edge in edges:3617 if edge.source_id in self._decisions:3618 incoming_causal_edges[edge.target_id].append(edge)36193620 def record_chain(cause_path):3621 """Record one chain. Returns False once the cap is reached."""3622 nonlocal truncated3623 if len(causal_chain) >= chain_limit:3624 truncated = True3625 return False3626 causal_chain.append(3627 self._build_causal_chain_report(list(reversed(cause_path)))3628 )3629 return True36303631 def trace_recursive(current_id, depth, path, path_ids):3632 # Cycle detection is per-path rather than global: a decision reached3633 # through one branch must stay traversable through another, otherwise3634 # branching graphs silently lose valid chains. max_depth bounds the3635 # traversal.3636 if truncated or depth >= max_depth or current_id in path_ids:3637 return36383639 path_ids = path_ids | {current_id}3640 current_decision = self._decisions[current_id]36413642 # Explicit causal relationships recorded via add_causal_relationship()3643 # take precedence - they are the ground truth the caller recorded.3644 # Every edge is traced, so parallel relationships between the same3645 # pair of decisions are all reported rather than overwriting.3646 explicit_causes = incoming_causal_edges.get(current_id, [])3647 explicit_cause_ids = {edge.source_id for edge in explicit_causes}36483649 for edge in explicit_causes:3650 cause_id = edge.source_id3651 cause_dec = self._decisions[cause_id]3652 weight = getattr(edge, "weight", None)3653 # A stored weight of 0.0 is meaningful and must not be coerced3654 # to the 1.0 default.3655 edge_weight = 1.0 if weight is None else float(weight)3656 hop = {3657 "from": cause_id,3658 "from_scenario": cause_dec.get("scenario", ""),3659 "to": current_id,3660 "to_scenario": current_decision.get("scenario", ""),3661 "type": edge.edge_type,3662 "edge_weight": edge_weight,3663 }3664 cause_path = path + [hop]3665 if not record_chain(cause_path):3666 return3667 trace_recursive(cause_id, depth + 1, cause_path, path_ids)3668 if truncated:3669 return显式边是调用者主动记录的「地面真值」。除此之外还有一层启发式补边:两个决策共享同一实体,且时间戳早于当前决策,就被当作潜在原因。
3671 # Find potential causes (decisions that influenced this one) via3672 # shared entities/timestamps - additive heuristic, skipping anything3673 # already covered by an explicit relationship above.3674 potential_causes = []3675 for entity in current_decision["entities"]:3676 for other_decision_id in self._entity_index.get(entity, set()):3677 if other_decision_id != current_id and other_decision_id not in explicit_cause_ids:3678 other_decision = self._decisions[other_decision_id]3679 if other_decision["timestamp"] < current_decision["timestamp"]:3680 potential_causes.append(other_decision_id)36813682 for cause_id in potential_causes:3683 cause_dec = self._decisions.get(cause_id, {})3684 edge_weight = float(cause_dec.get("confidence", 1.0))3685 hop = {3686 "from": cause_id,3687 "from_scenario": cause_dec.get("scenario", ""),3688 "to": current_id,3689 "to_scenario": current_decision.get("scenario", ""),3690 "type": "influences",3691 "edge_weight": edge_weight,3692 }3693 cause_path = path + [hop]3694 if not record_chain(cause_path):3695 returnCausalChainAnalyzer 还有一个面向解释的入口 interpret_causal_distance,在第 682 行定义。它只沿因果类型的边做广度优先,沿途把边权连乘成 confidence_decay,记下最弱的那条边作为 weakest_link,再按跳数套上直接、间接、远端三档解释。第 752 行的 _causal_interpretation 把跳数和衰减翻译成一句人话:跳数少、衰减高就是直接原因,跳数多、衰减低就是弱信号。审计员最终拿到的是这句解释,节点列表只是中间产物。
对带 Cypher 的图后端,因果链查询走的是另一条路。CausalChainAnalyzer.get_causal_chain 用可变长度路径把上游或下游一次性拉出来。
91 def get_causal_chain(92 self,93 decision_id: str,94 direction: str = "upstream",95 max_depth: int = 1096 ) -> List[Decision]:97 """98 Trace decision causality in specified direction.99 100 Args:101 decision_id: Starting decision ID102 direction: "upstream" (what caused this) or "downstream" (what this caused)103 max_depth: Maximum traversal depth104 105 Returns:106 List of decisions in causal chain107 """108 try:109 if hasattr(self.graph_store, "get_causal_chain") and not hasattr(self.graph_store, "execute_query"):110 return self.graph_store.get_causal_chain(111 decision_id=decision_id,112 direction=direction,113 max_depth=max_depth114 )115116 if not (1 <= max_depth <= 100):117 raise ValueError("max_depth must be between 1 and 20")118119 if direction not in ["upstream", "downstream"]:120 raise ValueError("Direction must be 'upstream' or 'downstream'")121 122 # Define relationship direction based on traversal direction123 if direction == "upstream":124 rel_pattern = "<-[:CAUSED|:INFLUENCED|:PRECEDENT_FOR]-"125 else:126 rel_pattern = "-[:CAUSED|:INFLUENCED|:PRECEDENT_FOR]->"127 128 query = f"""129 MATCH (start:Decision {{decision_id: $decision_id}})130 MATCH path = (start){rel_pattern}{{1,{max_depth}}}(end:Decision)131 RETURN DISTINCT end, length(path) as distance132 ORDER BY distance, end.timestamp133 """134 135 results = self.graph_store.execute_query(query, {136 "decision_id": decision_id137 })138 results = self._extract_records(results)139 140 decisions = []141 for record in results:142 decision_data = record.get("end") if isinstance(record, dict) else None143 if not isinstance(decision_data, dict):144 decision_data = record if isinstance(record, dict) else {}145 decision = self._dict_to_decision(decision_data)146 decision.metadata["causal_distance"] = record.get("distance", 0)147 decisions.append(decision)148 149 self.logger.info(f"Found {len(decisions)} decisions in {direction} causal chain")150 return decisions151 152 except Exception as e:153 self.logger.error(f"Failed to get causal chain: {e}")154 raiseCypher 路径里 {1,max_depth} 是可变长度路径,DISTINCT 去重,ORDER BY distance 保证跳数少的排前面。方向由 direction 参数决定,上游用反向箭头 <-[:CAUSED|:INFLUENCED|:PRECEDENT_FOR]-,下游用正向箭头。
这个类本身的构造很薄,只存一个 graph_store 和 logger,把真正的遍历逻辑交给后端。
72class CausalChainAnalyzer:73 """74 Analyzes causal chains between decisions.75 76 This class provides methods for tracing decision causality, finding77 decisions that influenced others, and analyzing precedent relationships78 using graph traversal.79 """80 81 def __init__(self, graph_store: Any):82 """83 Initialize CausalChainAnalyzer.84 85 Args:86 graph_store: Graph database instance for traversal87 """88 self.graph_store = graph_store89 self.logger = get_logger(__name__)查询:按先例和影响找人
模块对自己职责的官方说法写在 context_usage.md 开头,它把「从过去的决策中学习」列进智能体的核心能力。
5The context module gives your AI agents the ability to **remember**, **learn**, and **make smarter decisions** by organizing information in a way that's both powerful and easy to use.67Think of it as giving your agent a brain that can:8- **Remember conversations** (like human memory)9- **Learn from past decisions** (become smarter over time)10- **Find relevant information** quickly (when it matters most)11- **Understand relationships** between concepts12- **Make consistent decisions** based on experience落到代码里,查询这步有三条粒度不同的路径。粗粒度是 ContextGraph.find_precedents_by_scenario,在 context_graph.py 第 4227 行定义,把场景文本与历史决策做内容相似度加结构相似度的混合打分,按分数排序取前几条。细粒度是 DecisionQuery.multi_hop_reasoning,在 decision_query.py 第 634 行定义,从起始实体出发在决策子图里做多跳推理,返回一路经过的决策和关系。ContextGraph.trace_decision_chain 则沿已连好的边把一条决策链整条拉出来,在第 5063 行定义。三条路径回答同一个问题:眼前这个新决策,历史上最像谁、受谁影响。混合打分把内容相似度、结构相似度和中心度加权合并,_calculate_hybrid_score 在 decision_query.py 第 993 行定义。
治理:策略与版本
决策写进图之后,要接受规则约束。策略本身也是一个数据模型,Policy dataclass 把规则、版本、创建与更新时间钉死。
174@dataclass175class Policy:176 """Policy with versioning and change tracking."""177 178 policy_id: str179 name: str180 description: str181 rules: Dict[str, Any]182 category: str183 version: str184 created_at: datetime185 updated_at: datetime186 metadata: Dict[str, Any] = field(default_factory=dict)187 188 def __post_init__(self, auto_generate_id: bool = True):189 """Validate policy data."""190 if auto_generate_id and not self.policy_id: # Handle both None and empty string191 self.policy_id = str(uuid.uuid4())192 elif not self.policy_id and not auto_generate_id:193 raise ValueError("policy_id is required when auto_generate_id=False")194 195 def to_dict(self) -> Dict[str, Any]:196 """Convert policy to dictionary."""197 return {198 "policy_id": self.policy_id,199 "name": self.name,200 "description": self.description,201 "rules": self.rules,202 "category": self.category,203 "version": self.version,204 "created_at": self.created_at.isoformat(),205 "updated_at": self.updated_at.isoformat(),206 "metadata": self.metadata207 }208 209 @classmethod210 def from_dict(cls, data: Dict[str, Any]) -> "Policy":211 """Create policy from dictionary."""212 for field in ["created_at", "updated_at"]:213 if isinstance(data.get(field), str):214 data[field] = datetime.fromisoformat(data[field])215 return cls(**data)PolicyEngine 管三件事:策略的增删改、版本的生成与链接、合规检查。
79class PolicyEngine:80 """81 Policy engine with versioning and change tracking.82 83 This class manages policies, tracks versions, checks compliance,84 records exceptions, and analyzes policy impact.85 """86 87 def __init__(self, graph_store: Any):88 """89 Initialize PolicyEngine.90 91 Args:92 graph_store: Graph database instance for storing policies93 """94 self.graph_store = graph_store95 self.logger = get_logger(__name__)96 self._supports_cypher = hasattr(graph_store, "execute_query")add_policy 在写入前先查重,policy_id 已存在就抛 ValueError,同 ID 的多个版本只能通过 update_policy 的版本化路径产生。策略更新采取追加新版本的方式,update_policy 把改动原因和上一版本号写进新策略的 metadata,再用 VERSION_OF 边把新旧版本连起来。
167 def update_policy(168 self,169 policy_id: str,170 rules: Dict[str, Any],171 change_reason: str,172 new_version: Optional[str] = None173 ) -> str:174 """175 Update policy and create new version.176 177 Args:178 policy_id: Policy ID to update179 rules: New policy rules180 change_reason: Reason for the change181 new_version: Optional new version (auto-generated if not provided)182 183 Returns:184 New version string185 """186 try:187 # Get current policy188 current_policy = self.get_policy(policy_id)189 if not current_policy:190 raise ValueError(f"Policy {policy_id} not found")191 192 # Generate new version if not provided193 if not new_version:194 new_version = self._generate_next_version(current_policy.version)195 196 # Create new policy version197 updated_policy = Policy(198 policy_id=policy_id,199 name=current_policy.name,200 description=current_policy.description,201 rules=rules,202 category=current_policy.category,203 version=new_version,204 created_at=current_policy.created_at,205 updated_at=datetime.now(),206 metadata={207 **current_policy.metadata,208 "change_reason": change_reason,209 "previous_version": current_policy.version210 }211 )212 213 # Store new version214 self.add_policy(updated_policy)215 216 if self._supports_cypher:217 query = """218 MATCH (old:Policy {policy_id: $policy_id, version: $old_version})219 MATCH (new:Policy {policy_id: $policy_id, version: $new_version})220 MERGE (old)-[:VERSION_OF]->(new)221 """222 self.graph_store.execute_query(query, {223 "policy_id": policy_id,224 "old_version": current_policy.version,225 "new_version": new_version226 })227 else:228 if hasattr(self.graph_store, "add_edge"):229 self.graph_store.add_edge(230 f"{policy_id}:{current_policy.version}",231 f"{policy_id}:{new_version}",232 edge_type="VERSION_OF",233 changed_at=datetime.now().isoformat(),234 change_reason=change_reason235 )236 237 self.logger.info(f"Updated policy {policy_id} to version {new_version}")238 return policy_id239 240 except Exception as e:241 self.logger.exception("Failed to update policy")242 raise_validate_policy_rules 和 _validate_version_format 是两条前置校验:数值型规则必须是数字,比率型规则必须在 0 到 1 之间,版本号必须匹配语义化版本格式。规则写错在入口就拦下。
合规检查的核心是一个前缀规则引擎。min_ 前缀要求字段不低于阈值,max_ 要求不高于阈值,required_ 要求字段包含列表或等于字符串,都不满足就返回 False。
896 def _evaluate_compliance(self, decision: Decision, rules: Dict[str, Any]) -> bool:897 """Evaluate decision compliance against policy rules."""898 metadata = decision.metadata or {}899900 for rule_key, rule_value in rules.items():901 # min_X → metadata["X"] >= rule_value902 if rule_key.startswith("min_"):903 field = rule_key[4:]904 field_value = self._get_metadata_field(metadata, decision, field)905 if field_value is None:906 return False907 if field_value < rule_value:908 return False909 # max_X → metadata["X"] <= rule_value910 elif rule_key.startswith("max_"):911 field = rule_key[4:]912 field_value = self._get_metadata_field(metadata, decision, field)913 if field_value is None:914 return False915 # For strings use lexicographic comparison916 if field_value > rule_value:917 return False918 # required_X → metadata["X"] contains all items in rule_value (list) or equals (str)919 elif rule_key.startswith("required_"):920 field = rule_key[9:]921 field_value = self._get_metadata_field(metadata, decision, field)922 if field_value is None:923 return False924 if isinstance(rule_value, list):925 if not all(item in field_value for item in rule_value):926 return False927 elif field_value != rule_value:928 return False929 # Direct checks from _check_compliance_with_rules930 elif rule_key in {"min_confidence", "allowed_outcomes", "required_categories"}:931 decision_data = {"confidence": decision.confidence, "outcome": decision.outcome,932 "category": decision.category}933 if not self._check_compliance_with_rules(decision_data, {rule_key: rule_value}):934 return False935 else:936 # Unknown rule key — check if field is present in metadata937 if rule_key not in metadata:938 return False939 return TrueContextGraph 里还有一套更轻的默认策略校验,直接对决策字典做四类检查,返回合规布尔值加违规清单加警告清单。
3726 def enforce_decision_policy(3727 self,3728 decision_data: Dict[str, Any],3729 policy_rules: Optional[Dict[str, Any]] = None3730 ) -> Dict[str, Any]:3731 """3732 Enforce policies on decision data.3733 3734 Args:3735 decision_data: Decision data to check3736 policy_rules: Policy rules to enforce3737 3738 Returns:3739 Policy enforcement results3740 """3741 # Simple policy enforcement implementation3742 violations = []3743 warnings = []3744 3745 # Default policy rules3746 default_rules = {3747 "min_confidence": 0.7,3748 "required_outcomes": ["approved", "rejected", "flagged"],3749 "required_metadata": ["decision_maker"],3750 "max_reasoning_length": 10003751 }3752 3753 rules = policy_rules or default_rules3754 3755 # Check confidence3756 if decision_data.get("confidence", 0) < rules.get("min_confidence", 0.7):3757 violations.append(f"Confidence too low: {decision_data.get('confidence', 0)}")3758 3759 # Check outcome3760 if decision_data.get("outcome") not in rules.get("required_outcomes", []):3761 violations.append(f"Invalid outcome: {decision_data.get('outcome')}")3762 3763 # Check required metadata3764 for required_field in rules.get("required_metadata", []):3765 if not decision_data.get(required_field):3766 violations.append(f"Missing required field: {required_field}")3767 3768 # Check reasoning length3769 reasoning = decision_data.get("reasoning", "")3770 if len(reasoning) > rules.get("max_reasoning_length", 1000):3771 warnings.append(f"Reasoning too long: {len(reasoning)} characters")3772 3773 return {3774 "compliant": len(violations) == 0,3775 "violations": violations,3776 "warnings": warnings,3777 "policy_rules": rules3778 }合规之外,PolicyEngine 还管两件审计相关的事。record_exception 在第 446 行定义,把一次策略例外写成 Exception 节点,连上 GRANTED_EXCEPTION 和 OVERRIDDEN_POLICY 两条边,例外是谁批的、为什么批都留在图上。get_affected_decisions 在第 589 行定义,策略版本更新后按 APPLIED_POLICY 边反向找出所有引用过旧版本的决策;analyze_policy_impact 在第 711 行做 what-if 推演,把新规则套到历史决策上,算出受影响数量和风险增量。策略改动的后果在落地前就能量化。
一个决策的治理状态可以画成这样:
审计:哈希链轨迹
最后一步是把整个决策过程导成一份改不动的审计件。_append_immutable_trace_events 把每个事件序列化后,和上一条事件的哈希拼在一起,再算 SHA256。
429 for event in events:430 event_type = event.get("event_type", "TRACE_EVENT")431 payload = event.get("payload", {})432 payload_json = json.dumps(payload, sort_keys=True, default=str)433 event_timestamp = datetime.now().isoformat()434 trace_id = f"{decision_id}:{next_index}"435 hash_input = (436 f"{decision_id}|{next_index}|{event_type}|{event_timestamp}|{payload_json}|{previous_hash}"437 )438 event_hash = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()439440 graph_store.execute_query(441 """442 MATCH (d:Decision {decision_id: $decision_id})443 CREATE (t:DecisionTraceEvent {444 trace_id: $trace_id,445 decision_id: $decision_id,446 event_index: $event_index,447 event_type: $event_type,448 event_timestamp: $event_timestamp,449 event_payload: $event_payload,450 previous_hash: $previous_hash,451 event_hash: $event_hash452 })453 MERGE (d)-[:HAS_TRACE_EVENT]->(t)454 """,455 {456 "decision_id": decision_id,457 "trace_id": trace_id,458 "event_index": next_index,459 "event_type": event_type,460 "event_timestamp": event_timestamp,461 "event_payload": payload_json,462 "previous_hash": previous_hash,463 "event_hash": event_hash,464 },465 )这套哈希链的结构在图 schema 里落了地。DecisionTraceEvent 节点带 previous_hash 和 event_hash 两个字段,加上唯一约束和索引。
339 "DecisionTraceEvent": {340 "properties": [341 "trace_id", "decision_id", "event_index", "event_type",342 "event_timestamp", "event_payload", "previous_hash", "event_hash"343 ],344 "constraints": ["decision_trace_id_unique"],345 "indexes": [346 "decision_trace_id_index",347 "decision_trace_event_index",348 "decision_trace_type_index",策略的身份是 policy_id 加 version 二元组。create_decision_constraints 在 graph_schema.py 里先尝试建复合唯一约束,失败再退回单列唯一,这让同 ID 不同版本的策略可以共存,VERSION_OF 链才能把历史版本串起来。
链条只追加不修改:任何一条历史事件被改动,后续所有 event_hash 都会失配。审计员要验证一件事,只需重算一遍哈希链,比对落库值和重算值是否一致。
这串哈希链的编排入口是 capture_decision_trace,在 decision_methods.py 第 218 行定义。它把一次决策过程拆成最多六类事件依次落库:DECISION_RECORDED、CROSS_SYSTEM_CONTEXT_CAPTURED、POLICIES_APPLIED、EXCEPTIONS_RECORDED、APPROVAL_CHAIN_RECORDED、PRECEDENTS_LINKED。每类事件先在内存里攒成 trace_events 列表,最后一次性交给 _append_immutable_trace_events 追加。事件之间除了哈希串,还连 NEXT_TRACE_EVENT 边,保证按时间顺序也能遍历。capture_decision_trace 还有一个兼容分支:graph_store 为 None 时只打日志不落库,直接返回决策 ID,那次决策就没有审计轨迹。为了让审计查询快,create_decision_indexes 给 trace 节点建了 event_index、event_type、event_timestamp 三个索引,在 graph_schema.py 第 155 行到第 157 行。按事件序号翻、按事件类型筛、按时间范围扫,都有索引兜底。
设计决策分析
决策被建模成图节点,日志字符串的写法被放弃。 这是整个模块的第一性决策。证据在图 schema 里:Decision 是独立标签,decision_id 有唯一约束,围绕它建立了 category、timestamp、outcome、confidence、maker 五个索引。把决策当节点,意味着它可以像实体一样被连边、被遍历、被索引,这也是「链进因果网」的前提。代价是写入路径变重,每次记录都要维护节点加索引。
同一份决策数据被写进两套存储。 record_decision 在写入 self.nodes 之外,又维护了 _decisions、_decision_index、_entity_index、_temporal_index 四个内部结构。前者是通用的图结构,后者是决策专用的倒排索引。好处是决策检索和因果追溯不需要扫全图;坏处是两条入口产生了同步隐患,这个隐患会在边界条件里炸出来。维护两套存储的代价是每次新增字段都要改两处,_store_decision_node 里内存分支和 Cypher 分支的字段列表就是证据。
因果边分显式与启发式两层。 源码注释把显式边称为「ground truth」,把启发式称为「additive heuristic」。这样设计是因为显式边依赖调用者记得主动连,而共享实体加时间戳的启发式能在没人连边时兜底。代价是启发式会引入噪音,一个共享实体加时间先后并不等于因果。
策略版本用追加加链接表达,不覆盖。 update_policy 生成新版本后建 VERSION_OF 边,旧版本留在图里。这个决策对应一个审计硬需求:决策必须能指回「当时适用哪一版规则」。如果策略原地覆盖,历史决策的合规依据就消失了。推断:这是为了满足审计回放而做的刻意设计,仓库文档没有直接说明,但 get_affected_decisions 按版本区间回查受影响决策,佐证了这个意图。
策略版本号落在决策到策略的边上。 apply_policies 解析出版本后,把 r.policy_version = p.version 写进 APPLIED_POLICY 边的属性。审计时不用猜,直接看边就知道当时挂的是哪一版。
审计采用哈希链。 每个事件的 event_hash 都混入了上一条的 previous_hash,形成单向链条。这与区块链同构,但不需要分布式共识,只需要一个不可篡改的追加结构。审计员验证成本是 O(n) 重算,篡改成本是 O(n) 重写,在单机构场景够用。
节点和边都带时态窗口。 ContextNode 和 ContextEdge 都有 valid_from 和 valid_until,is_active 在给定时间点判断是否生效,决策本身也带这两个字段。这意味着图谱里的「现在」是可回放的,审计员能问三个月前这张图长什么样。
关系类型用两套词汇归一化。 同一个因果边在写入口叫 CAUSED,在读路径的 CausalChainAnalyzer 里又接受 causes、leads_to、supports 等现在时拼写。_CAUSAL_EDGE_ALIASES 表在存储前把现在时归一到规范拼写,_CAUSAL_TRAVERSAL_TYPES 在遍历时同时接受两套。归一化落在存储边界,保证图里只有一种写法,又兼容历史调用。代价是两张表必须同步维护,漏加一个拼写就会出现写读不对称,边界条件里已经演示过。
DecisionRecorder 和 ContextGraph 各自有一套 record_decision。 前者面向 GraphStore 抽象,后者面向内存 ContextGraph。两套 API 职责重叠,推断是模块演进中先后加入的结果,仓库没有文档说明二者的分工边界。这个重叠直接制造了下一节的第一个边界问题。
边界条件剖析
如果决策用 add_decision 写入,没有走 record_decision,会怎样? trace_decision_causality 在进入遍历前先检查 not hasattr(self, '_decisions') or decision_id not in self._decisions,不满足就抛 ValueError("Decision X not found")。而 _decisions 字典只在 record_decision 里初始化并填充,add_decision 只写 self.nodes。所以一个通过 add_decision 成功落库的决策节点,在因果追溯里会被当成不存在,尽管它在 self.nodes 里能查到。分裂点落在 context_graph.py 的第 4474 行和第 4209 行:写入和读取用了两套索引。
如果给 add_causal_relationship 传 "leads_to",会怎样? 写入口用 _CAUSAL_EDGE_ALIASES 查表,这个表只有 CAUSES、CAUSED、INFLUENCES、INFLUENCED、PRECEDES、PRECEDENT_FOR 六种拼写,查不到就抛 ValueError。但 interpret_causal_distance 里的 CAUSAL_TYPES 集合额外接受 leads_to、supports 等现在时拼写。结果是写路径比读路径更严:一个读路径认的关系类型,在写路径会被拒绝。分裂点落在第 3650 行。
如果给 get_causal_chain 传 max_depth=50,会怎样? 守卫条件是 not (1 <= max_depth <= 100),50 能通过;但异常消息写的是 "max_depth must be between 1 and 20"。也就是说只有传大于 100 的值才会触发异常,而异常文案声称上限是 20。检查条件与错误消息不一致,落在第 116 行到第 117 行。
如果同一对决策之间重复连两条相同类型的因果边,会怎样? 不会去重。每条边写入时都带 metadata={"recorded_at": datetime.utcnow().isoformat()},时间戳不同导致 ContextEdge 的边 ID 不同,两条边都会留在图里。trace_decision_causality 的注释明确说平行边全部报告、不覆盖。落在第 3671 行和第 4522 行。
如果给 apply_policies 传一个不存在的策略 ID,会怎样? 不会抛错。查询查不到记录时,代码在 else 分支里只打一条 warning,applied 列表保持为空,调用方拿到的就是一个空列表。策略挂载失败是静默降级的,只有日志里能看到。落在 decision_recorder.py 第 258 行到第 262 行。
横向对比
同样是「给一个问题找答案」,GraphRAG 的 local search 走的是检索式问答,Semantica 的决策模块走的是决策记录。看 GraphRAG 的查询入口。
56 async def search(57 self,58 query: str,59 conversation_history: ConversationHistory | None = None,60 **kwargs,61 ) -> SearchResult:62 """Build local search context that fits a single context window and generate answer for the user query."""63 start_time = time.time()64 search_prompt = ""65 llm_calls, prompt_tokens, output_tokens = {}, {}, {}66 context_result = self.context_builder.build_context(67 query=query,68 conversation_history=conversation_history,69 **kwargs,70 **self.context_builder_params,71 )72 llm_calls["build_context"] = context_result.llm_calls73 prompt_tokens["build_context"] = context_result.prompt_tokens74 output_tokens["build_context"] = context_result.output_tokens它先调 context_builder.build_context 把上下文拼出来,再交给大模型流式生成回答,最后返回一个 SearchResult。答案生成这一段完全在内存里完成。
92 messages_builder = (93 CompletionMessagesBuilder()94 .add_system_message(search_prompt)95 .add_user_message(query)96 )9798 full_response = ""99100 response: AsyncIterator[101 LLMCompletionChunk102 ] = await self.model.completion_async(103 messages=messages_builder.build(),104 stream=True,105 **self.model_params,106 ) # type: ignore107108 async for chunk in response:109 response_text = chunk.choices[0].delta.content or ""110 full_response += response_text111 for callback in self.callbacks:112 callback.on_llm_new_token(response_text)113114 llm_calls["response"] = 1115 prompt_tokens["response"] = len(self.tokenizer.encode(search_prompt))116 output_tokens["response"] = len(self.tokenizer.encode(full_response))117118 for callback in self.callbacks:119 callback.on_context(context_result.context_records)120121 return SearchResult(所谓的上下文,是把选中的实体拼成一张文本表。build_entity_context 拼出 -----Entities----- 表头,逐行把实体 id、标题、描述用竖线连接,塞进 system prompt。
30def build_entity_context(31 selected_entities: list[Entity],32 tokenizer: Tokenizer | None = None,33 max_context_tokens: int = 8000,34 include_entity_rank: bool = True,35 rank_description: str = "number of relationships",36 column_delimiter: str = "|",37 context_name="Entities",38) -> tuple[str, pd.DataFrame]:39 """Prepare entity data table as context data for system prompt."""40 tokenizer = tokenizer or get_tokenizer()4142 if len(selected_entities) == 0:43 return "", pd.DataFrame()4445 # add headers46 current_context_text = f"-----{context_name}-----" + "\n"47 header = ["id", "entity", "description"]48 if include_entity_rank:49 header.append(rank_description)50 attribute_cols = (51 list(selected_entities[0].attributes.keys())52 if selected_entities[0].attributes53 else []54 )55 header.extend(attribute_cols)56 current_context_text += column_delimiter.join(header) + "\n"57 current_tokens = tokenizer.num_tokens(current_context_text)对比下来,两侧对「回答问题」的分歧有三条。其一,GraphRAG 的答案不落库,没有决策 ID、没有时间戳、没有决策者,SearchResult 里只有 response、context 和 token 统计;Semantica 的答案必须落成一个带 ID 的 Decision 节点。其二,GraphRAG 没有策略版本概念,它的「规则」是大模型 prompt 里的约束;Semantica 有 PolicyEngine 和 VERSION_OF 链,决策必须能指回当时适用的规则版本。其三,GraphRAG 没有哈希审计链,答案生成后随时可被重新生成且不保留过程;Semantica 用 DecisionTraceEvent 把每一步都追加进哈希链。
为什么 GraphRAG 可以没有决策层?因为它的每次查询相互独立,答案可以被随时重算,终点就是返回给用户的文本。Semantica 的定位是决策可审计,决策是资产,会被后续决策引用,被审计员回放。检索证据:在 graphrag/query/ 下检索 decision、policy_engine、audit trail 三个关键词,唯一命中是 rate_relevancy.py 第 77 行注释里的「select the decision with the most votes」,它指的是相关性评分投票取多数,和决策记录无关。也就是说 GraphRAG 的查询层确实没有决策记录、策略引擎、审计链三样东西,它用「上下文片段加引用」补上了答案可解释性,但补不上答案可审计性。
再把两侧的代价并排看。GraphRAG 的 local search 每次查询都要重新做实体检索和上下文拼接,token 开销随上下文宽度线性增长,换来的是零持久化负担,答案错了重新生成即可。Semantica 的决策记录每次写入都要维护节点、索引、版本、哈希链四套结构,写入重,换来的是答案可回放、可指认、可审计。两种选择对应两种场景:阅读语料的开放问答选前者,受监管的决策场景选后者。
互动演示设计
一句话结论:把一个决策从一句话输出变成图里可追溯的节点,靠的是「记录、连边、查询、治理、审计」五个工位接力。
舞台元素与比喻:一条五工位的流水线沙盘。决策是一块「工件」,每经过一个工位就被打上一个刻痕。记录工位给工件刻 ID,连边工位把工件和别的工件拴上线,查询工位翻旧工件找相似款,治理工位盖合规章并记下章是第几版,审计工位给每道刻痕套上只增不改的链条。
分步动画:
每步字幕:第一步「决策落成节点,八个字段钉死」;第二步「显式边优先,启发式兜底」;第三步「按场景找先例,避免拍脑袋」;第四步「记录 applied 的策略版本号」;第五步「每个事件混入上一哈希,只增不改」。
读者可操作项:在沙盘上把 record_decision 的 confidence 从 0.94 改成 1.5,观察记录工位拒绝通过;把一条 add_causal_relationship 的关系类型从 CAUSED 改成 leads_to,观察连边工位抛错;给一个决策连出三条相同因果边,观察审计工位照单全收;再挂一个不存在的策略 ID,观察治理工位只打日志不报错。
每个实验的观察结论:置信度越界验证了 __post_init__ 的 0 到 1 约束;关系类型报错验证了写路径的词汇白名单;三条边共存验证了 recorded_at 参与边 ID 计算;策略 ID 静默失败验证了 apply_policies 的降级分支。
逻辑轨迹面板伪代码,右侧标真实行号:
record_decision(d) # 决策落节点,第 115 行
store_node(d) # 双后端分支,第 469 行
track_provenance(d) # PROV 溯源,第 587 行
add_causal_relationship(a, b, ...) # 显式连边,第 3629 行
trace_decision_causality(b) # 递归回溯,第 4453 行
update_policy(p, rules, reason) # 版本化,第 167 行
_evaluate_compliance(d, rules) # 合规检查,第 896 行
append_immutable_trace_events(...) # 哈希链,第 388 行时序图补全审计工位的内部协作:
可迁移结论
值得抄的第一条:决策对象化。 把大模型输出从字符串升级成带 ID、时间戳、决策者、置信度的记录,是后续所有追溯的前提。最小成本形态就是一个 dataclass 加一张表,八个字段直接照抄,confidence 的 0 到 1 校验也照抄。真正值钱的是 decision_maker 和 timestamp 两个字段:审计追问的永远是「谁在什么时候拍板的」,缺了它们,再完整的推理文本也回答不了。这条不依赖 Python,任何语言都能在半天内落地,电子表格加一列决策 ID 也算起步。
值得抄的第二条:策略版本用追加加链接表达。 审计真正要问的是「当时适用哪一版规则」,所以规则改动的正确表达是「新版本加一条 VERSION_OF 边,旧版本留着」,加上 change_reason 和 previous_version 两个字段。最小成本形态是给规则表加 version 列,改动时插入新行,不做原地 UPDATE。change_reason 值得单独强调,它把「为什么改」写进数据,事后回看不用翻会议纪要。
值得抄的第三条:哈希链审计。 previous_hash 串进下一个 event_hash 的单向链条,是单机构场景下成本最低的防篡改手段。验证只需重算,不需要额外的密钥体系。最小成本形态是一列 previous_hash 加一列 event_hash,写入时串接再 SHA256。注意它的边界:它防篡改,不防初始写入错误,所以源头决策对象本身仍要有主键和校验。
把五步压成一句可迁移的审计口诀:任何决策系统,先问「谁在什么时候拍板的」,再问「当时适用哪版规则」,再问「这个决定影响了谁」,最后问「过程能不能被验证」。Semantica 的答案分别是 decision_maker 加 timestamp、策略版本加 VERSION_OF 链、因果边、哈希链。这张问题清单不依赖任何技术栈。
过度设计要警惕。 一是 ContextGraph 内部 _decisions 与 nodes 双存储造成的入口分裂,小项目抄的时候只用一套索引就够。二是 DecisionRecorder 与 ContextGraph 两套 record_decision 的职责重叠,属于演进遗留,不值得抄。三是因果启发式这层「共享实体加时间戳等于潜在因果」的推断,在决策样本少的时候噪音远大于信号,显式边一条就够起步。四是两套关系词汇归一化表 _CAUSAL_EDGE_ALIASES 加 _CAUSAL_TRAVERSAL_TYPES 的同步维护负担,单项目只保留一种拼写即可。
思考题
-
add_decision和record_decision写出的决策,在trace_decision_causality眼里有什么不同?请指到具体行号说明差异的根源。 -
动手验证:在 semantica 仓库根目录运行下面这段,观察输出,再解释为什么报错信息里的决策明明存在。
python3 -c "from semantica.context import ContextGraph, Decision; from datetime import datetime; g = ContextGraph(); d = Decision(decision_id='d1', category='c', scenario='s', reasoning='r', outcome='o', confidence=0.9, timestamp=datetime.now(), decision_maker='m'); g.add_decision(d); print(g.trace_decision_causality('d1'))"-
_evaluate_compliance里min_前缀规则在字段值缺失时返回 False。请说明这个设计的审计含义:字段缺失为什么判为不合规,为什么不允许跳过。 -
哈希链的
previous_hash是从哪里取的?如果一条历史DecisionTraceEvent被删除,验证者能不能发现?请结合_append_immutable_trace_events的查询与写入逻辑回答。 -
apply_policies解析策略版本时,显式版本和空版本分别走哪条分支?为什么ORDER BY p.updated_at DESC, p.version DESC配LIMIT 1能保证只命中一个版本? -
trace_decision_causality的max_chains默认是 10000。请说明为什么密集图需要这个上限,截断时返回的结构里带什么标记提醒调用方。