杂乱数据如何变成一张可解释、可审计的知识图谱 9 / 10

第 9 章

上下文与决策智能

源码核对基于 semantica-agi/semantica commit `1ee2ae88`,tag `course-anchor-20260824`

场景还原

季度审计的前一天晚上,合规审计员小李要回答一个问题:三个月前那笔五百万授信为什么批了。系统里能查到的只有三样东西:大模型生成的回答文本、一个 0.94 的置信度、以及一个「approved」结果。当时适用的是哪一版授信政策?政策在那之后改过没有?是谁在 Slack 里口头批准的例外?这些问题的答案散落在聊天记录、邮件和某个同事的脑子里。小李花了两天拼出一条时间线,最后还是有两处对不上。问题在于,模型答完之后,依据、版本、批准过程全都没有被结构化留下来。小李要的是一次能经得起重算的证据链,一行回答文本不够。

这就是本章要解决的问题。前面八章把杂乱数据变成了一张可解释的知识图谱,但图谱里的节点是实体和关系,缺了「人做过什么决定」这个维度。企业里最需要被审计的恰好是这个维度。Semantica 的 context 模块是全仓库最大的模块,约 1.9 万行、17 个 Python 文件,它做的事可以压缩成一条五步流水线:记录、连边、查询、治理、审计。本章沿这五步走一遍,看一个决策如何从「一句话输出」变成图里可追溯的一等公民。

逐行精读

记录:让决策成为一等节点

先看决策的载体。Semantica 把决策定义成一个 dataclass,八个必填字段把「谁、在什么时候、基于什么推理、得出了什么结果」一次性钉死。

semantica/context/decision_models.py86:136
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_fromvalid_until 两个字段让决策本身也带时态窗口。这意味着决策从出生的那一刻起就自带主键和置信度约束,后面所有环节都能引用它。

决策落进内存图时,外面再包一层 ContextNode,它才是图里的实际节点结构。

semantica/context/context_graph.py321:361
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_fromvalid_untilis_active 在给定时间点判断节点是否还在生效期。决策节点落到图里时,categoryreasoningoutcome 这些字段都被塞进 properties,成为可查询的属性。

reasoning_embeddingnode2vec_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 按固定顺序做四件事:生成嵌入、存节点、连实体、记溯源。

semantica/context/decision_recorder.py115:154
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 先判断后端类型:内存里的 ContextGraphadd_node,带 Cypher 的图数据库走 CREATE (d:Decision {...})

semantica/context/decision_recorder.py469:519
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 溯源层。

semantica/context/decision_recorder.py587:618
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 边,把版本号写进边的属性。

semantica/context/decision_recorder.py226:238
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 DESCLIMIT 1 保证同 ID 多版本时只命中一个,MERGE 保证这条边幂等。这一步让「决策当时适用哪版策略」成为可查询的事实。

连边:把决策链进因果网

记录只是把决策写进图。决策的价值在于它和别的决策、别的实体之间有线。ContextGraph.add_causal_relationship 是显式连边的入口,它先规范化关系类型,再校验两端节点确实存在且确实是决策节点。

semantica/context/context_graph.py2751:2795
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,然后反向建立显式因果边的索引,再递归回溯。

semantica/context/context_graph.py3596:3669
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

显式边是调用者主动记录的「地面真值」。除此之外还有一层启发式补边:两个决策共享同一实体,且时间戳早于当前决策,就被当作潜在原因。

semantica/context/context_graph.py3671:3695
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                        return

CausalChainAnalyzer 还有一个面向解释的入口 interpret_causal_distance,在第 682 行定义。它只沿因果类型的边做广度优先,沿途把边权连乘成 confidence_decay,记下最弱的那条边作为 weakest_link,再按跳数套上直接、间接、远端三档解释。第 752 行的 _causal_interpretation 把跳数和衰减翻译成一句人话:跳数少、衰减高就是直接原因,跳数多、衰减低就是弱信号。审计员最终拿到的是这句解释,节点列表只是中间产物。

对带 Cypher 的图后端,因果链查询走的是另一条路。CausalChainAnalyzer.get_causal_chain 用可变长度路径把上游或下游一次性拉出来。

semantica/context/causal_analyzer.py91:154
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            raise

Cypher 路径里 {1,max_depth} 是可变长度路径,DISTINCT 去重,ORDER BY distance 保证跳数少的排前面。方向由 direction 参数决定,上游用反向箭头 <-[:CAUSED|:INFLUENCED|:PRECEDENT_FOR]-,下游用正向箭头。

这个类本身的构造很薄,只存一个 graph_store 和 logger,把真正的遍历逻辑交给后端。

semantica/context/causal_analyzer.py72:89
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 开头,它把「从过去的决策中学习」列进智能体的核心能力。

semantica/context/context_usage.md5:12
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 把规则、版本、创建与更新时间钉死。

semantica/context/decision_models.py174:215
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 管三件事:策略的增删改、版本的生成与链接、合规检查。

semantica/context/policy_engine.py79:96
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 边把新旧版本连起来。

semantica/context/policy_engine.py167:242
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。

semantica/context/policy_engine.py896:939
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 True

ContextGraph 里还有一套更轻的默认策略校验,直接对决策字典做四类检查,返回合规布尔值加违规清单加警告清单。

semantica/context/context_graph.py3726:3778
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_EXCEPTIONOVERRIDDEN_POLICY 两条边,例外是谁批的、为什么批都留在图上。get_affected_decisions 在第 589 行定义,策略版本更新后按 APPLIED_POLICY 边反向找出所有引用过旧版本的决策;analyze_policy_impact 在第 711 行做 what-if 推演,把新规则套到历史决策上,算出受影响数量和风险增量。策略改动的后果在落地前就能量化。

一个决策的治理状态可以画成这样:

stateDiagram-v2 [*] --> 记录决策 记录决策 --> 应用策略 应用策略 --> 合规通过 应用策略 --> 记录例外 记录例外 --> 授权批准 授权批准 --> 审计轨迹 合规通过 --> 审计轨迹 审计轨迹 --> [*]

审计:哈希链轨迹

最后一步是把整个决策过程导成一份改不动的审计件。_append_immutable_trace_events 把每个事件序列化后,和上一条事件的哈希拼在一起,再算 SHA256。

semantica/context/decision_methods.py429:465
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_hashevent_hash 两个字段,加上唯一约束和索引。

semantica/context/graph_schema.py339:348
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) 重写,在单机构场景够用。

节点和边都带时态窗口。 ContextNodeContextEdge 都有 valid_fromvalid_untilis_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_chainmax_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 的查询入口。

packages/graphrag/graphrag/query/structured_search/local_search/search.py56:74
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。答案生成这一段完全在内存里完成。

packages/graphrag/graphrag/query/structured_search/local_search/search.py92:121
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。

packages/graphrag/graphrag/query/context_builder/local_context.py30:57
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 有 PolicyEngineVERSION_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,连边工位把工件和别的工件拴上线,查询工位翻旧工件找相似款,治理工位盖合规章并记下章是第几版,审计工位给每道刻痕套上只增不改的链条。

分步动画:

flowchart LR A[记录工位\n刻决策 ID 与置信度] --> B[连边工位\n拴因果线与先例线] B --> C[查询工位\n翻相似旧决策] C --> D[治理工位\n盖策略版本章] D --> E[审计工位\n套哈希链]

每步字幕:第一步「决策落成节点,八个字段钉死」;第二步「显式边优先,启发式兜底」;第三步「按场景找先例,避免拍脑袋」;第四步「记录 applied 的策略版本号」;第五步「每个事件混入上一哈希,只增不改」。

读者可操作项:在沙盘上把 record_decisionconfidence 从 0.94 改成 1.5,观察记录工位拒绝通过;把一条 add_causal_relationship 的关系类型从 CAUSED 改成 leads_to,观察连边工位抛错;给一个决策连出三条相同因果边,观察审计工位照单全收;再挂一个不存在的策略 ID,观察治理工位只打日志不报错。

每个实验的观察结论:置信度越界验证了 __post_init__ 的 0 到 1 约束;关系类型报错验证了写路径的词汇白名单;三条边共存验证了 recorded_at 参与边 ID 计算;策略 ID 静默失败验证了 apply_policies 的降级分支。

逻辑轨迹面板伪代码,右侧标真实行号:

text
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 行

时序图补全审计工位的内部协作:

sequenceDiagram participant R as DecisionRecorder participant G as GraphStore participant P as ProvenanceManager R->>G: 写 Decision 节点\n第 469 行 R->>G: 连 ABOUT 边\n第 156 行 R->>P: track_entity 登记实体\n第 598 行 R->>G: 追加哈希链事件\n第 388 行

可迁移结论

值得抄的第一条:决策对象化。 把大模型输出从字符串升级成带 ID、时间戳、决策者、置信度的记录,是后续所有追溯的前提。最小成本形态就是一个 dataclass 加一张表,八个字段直接照抄,confidence 的 0 到 1 校验也照抄。真正值钱的是 decision_makertimestamp 两个字段:审计追问的永远是「谁在什么时候拍板的」,缺了它们,再完整的推理文本也回答不了。这条不依赖 Python,任何语言都能在半天内落地,电子表格加一列决策 ID 也算起步。

值得抄的第二条:策略版本用追加加链接表达。 审计真正要问的是「当时适用哪一版规则」,所以规则改动的正确表达是「新版本加一条 VERSION_OF 边,旧版本留着」,加上 change_reasonprevious_version 两个字段。最小成本形态是给规则表加 version 列,改动时插入新行,不做原地 UPDATE。change_reason 值得单独强调,它把「为什么改」写进数据,事后回看不用翻会议纪要。

值得抄的第三条:哈希链审计。 previous_hash 串进下一个 event_hash 的单向链条,是单机构场景下成本最低的防篡改手段。验证只需重算,不需要额外的密钥体系。最小成本形态是一列 previous_hash 加一列 event_hash,写入时串接再 SHA256。注意它的边界:它防篡改,不防初始写入错误,所以源头决策对象本身仍要有主键和校验。

把五步压成一句可迁移的审计口诀:任何决策系统,先问「谁在什么时候拍板的」,再问「当时适用哪版规则」,再问「这个决定影响了谁」,最后问「过程能不能被验证」。Semantica 的答案分别是 decision_maker 加 timestamp、策略版本加 VERSION_OF 链、因果边、哈希链。这张问题清单不依赖任何技术栈。

过度设计要警惕。 一是 ContextGraph 内部 _decisionsnodes 双存储造成的入口分裂,小项目抄的时候只用一套索引就够。二是 DecisionRecorderContextGraph 两套 record_decision 的职责重叠,属于演进遗留,不值得抄。三是因果启发式这层「共享实体加时间戳等于潜在因果」的推断,在决策样本少的时候噪音远大于信号,显式边一条就够起步。四是两套关系词汇归一化表 _CAUSAL_EDGE_ALIASES_CAUSAL_TRAVERSAL_TYPES 的同步维护负担,单项目只保留一种拼写即可。

思考题

  1. add_decisionrecord_decision 写出的决策,在 trace_decision_causality 眼里有什么不同?请指到具体行号说明差异的根源。

  2. 动手验证:在 semantica 仓库根目录运行下面这段,观察输出,再解释为什么报错信息里的决策明明存在。

text
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'))"
  1. _evaluate_compliancemin_ 前缀规则在字段值缺失时返回 False。请说明这个设计的审计含义:字段缺失为什么判为不合规,为什么不允许跳过。

  2. 哈希链的 previous_hash 是从哪里取的?如果一条历史 DecisionTraceEvent 被删除,验证者能不能发现?请结合 _append_immutable_trace_events 的查询与写入逻辑回答。

  3. apply_policies 解析策略版本时,显式版本和空版本分别走哪条分支?为什么 ORDER BY p.updated_at DESC, p.version DESCLIMIT 1 能保证只命中一个版本?

  4. trace_decision_causalitymax_chains 默认是 10000。请说明为什么密集图需要这个上限,截断时返回的结构里带什么标记提醒调用方。