@@ -4133,6 +4133,335 @@ def get_state(self, state_version_id: str) -> dict[str, Any] | None:
41334133 "review_tasks": [item.to_dict() for item in bundle["review_tasks"]],
41344134 }
41354135
4136+ def state_graph_report(self, state_version_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
4137+ payload = dict(payload or {})
4138+ bundle = self.repository.get_state_bundle(state_version_id)
4139+ state = self.repository.get_state_version(state_version_id)
4140+ if bundle is None or state is None:
4141+ raise LookupError(f"state not found: {state_version_id}")
4142+
4143+ objects: list[TwmStateObject] = list(bundle.get("objects") or [])
4144+ relations: list[TwmStateRelation] = list(bundle.get("relations") or [])
4145+ rule_hits: list[TwmRuleHit] = self.repository.list_rule_hits(state_version_id=state_version_id)
4146+ support_materials: list[TwmEvidenceItem] = self.repository.list_evidence_items(state_version_id=state_version_id)
4147+ review_tasks: list[TwmReviewTask] = self.repository.list_review_tasks(state_version_id=state_version_id)
4148+
4149+ object_by_id = {item.id: item for item in objects}
4150+ hit_by_id = {item.id: item for item in rule_hits}
4151+ support_by_hit: dict[str, list[TwmEvidenceItem]] = {}
4152+ for item in support_materials:
4153+ support_by_hit.setdefault(item.rule_hit_id, []).append(item)
4154+ review_by_hit: dict[str, list[TwmReviewTask]] = {}
4155+ for item in review_tasks:
4156+ review_by_hit.setdefault(item.rule_hit_id, []).append(item)
4157+
4158+ object_counts: dict[str, int] = {}
4159+ for obj in objects:
4160+ role = self._state_graph_object_role(obj)
4161+ object_counts[role] = object_counts.get(role, 0) + 1
4162+ relation_counts: dict[str, int] = {}
4163+ for rel in relations:
4164+ rel_type = compact_text(rel.relation_type or rel.predicate or "unknown")
4165+ relation_counts[rel_type] = relation_counts.get(rel_type, 0) + 1
4166+ support_counts: dict[str, int] = {}
4167+ for item in support_materials:
4168+ support_type = compact_text(item.evidence_type or "support_material")
4169+ support_counts[support_type] = support_counts.get(support_type, 0) + 1
4170+
4171+ nodes: list[dict[str, Any]] = []
4172+ edges: list[dict[str, Any]] = []
4173+ node_ids: set[str] = set()
4174+
4175+ def add_node(node: dict[str, Any]) -> None:
4176+ node_id = compact_text(node.get("id"))
4177+ if not node_id or node_id in node_ids:
4178+ return
4179+ node_ids.add(node_id)
4180+ nodes.append(node)
4181+
4182+ def add_edge(edge: dict[str, Any]) -> None:
4183+ source = compact_text(edge.get("source"))
4184+ target = compact_text(edge.get("target"))
4185+ if not source or not target or source not in node_ids or target not in node_ids:
4186+ return
4187+ edges.append(edge)
4188+
4189+ for obj in objects:
4190+ add_node(self._state_graph_object_node(obj))
4191+ for hit in rule_hits:
4192+ add_node(self._state_graph_rule_hit_node(hit))
4193+ for item in support_materials:
4194+ add_node(self._state_graph_support_material_node(item))
4195+ for task in review_tasks:
4196+ add_node(self._state_graph_review_task_node(task))
4197+
4198+ for rel in relations:
4199+ add_edge({
4200+ "id": f"relation:{rel.id}",
4201+ "source": rel.subject_object_id,
4202+ "target": rel.object_object_id,
4203+ "kind": "state_relation",
4204+ "label": compact_text(rel.relation_type or rel.predicate or "关联"),
4205+ "predicate": compact_text(rel.predicate or rel.relation_type),
4206+ "confidence": safe_float(rel.confidence, 0.0),
4207+ "metrics": jsonable(rel.metrics or {}),
4208+ })
4209+ for hit in rule_hits:
4210+ hit_node_id = f"rule_hit:{hit.id}"
4211+ if hit.subject_object_id in node_ids:
4212+ add_edge({
4213+ "id": f"rule_subject:{hit.id}",
4214+ "source": hit.subject_object_id,
4215+ "target": hit_node_id,
4216+ "kind": "rule_subject",
4217+ "label": "触发规则判断",
4218+ "severity": hit.severity,
4219+ })
4220+ if hit.target_object_id and hit.target_object_id in node_ids:
4221+ add_edge({
4222+ "id": f"rule_target:{hit.id}",
4223+ "source": hit_node_id,
4224+ "target": hit.target_object_id,
4225+ "kind": "rule_target",
4226+ "label": "涉及管控对象",
4227+ "severity": hit.severity,
4228+ })
4229+ for item in support_by_hit.get(hit.id, []):
4230+ add_edge({
4231+ "id": f"support:{item.id}:hit:{hit.id}",
4232+ "source": f"support:{item.id}",
4233+ "target": hit_node_id,
4234+ "kind": "support_material",
4235+ "label": "支撑判断",
4236+ "support_type": item.evidence_type,
4237+ })
4238+ for task in review_by_hit.get(hit.id, []):
4239+ add_edge({
4240+ "id": f"review:{task.id}:hit:{hit.id}",
4241+ "source": hit_node_id,
4242+ "target": f"review:{task.id}",
4243+ "kind": "review_task",
4244+ "label": "形成复核任务",
4245+ "status": task.status,
4246+ })
4247+
4248+ visual_limit = max(1, safe_int(payload.get("visual_node_limit"), 160))
4249+ focus_object_id = compact_text(payload.get("focus_object_id") or payload.get("focus_node_id"))
4250+ visual_graph = self._state_graph_visual_subset(nodes, edges, visual_limit=visual_limit, focus_node_id=focus_object_id)
4251+ include_full_graph = truthy(payload.get("include_full_graph"))
4252+
4253+ full_counts = {
4254+ "state_object_count": len(objects),
4255+ "state_relation_count": len(relations),
4256+ "rule_hit_count": len(rule_hits),
4257+ "support_material_count": len(support_materials),
4258+ "review_task_count": len(review_tasks),
4259+ "total_node_count": len(nodes),
4260+ "total_edge_count": len(edges),
4261+ }
4262+ return {
4263+ "schema": "territory_world_model.state_graph.v1",
4264+ "state_version_id": state_version_id,
4265+ "project_id": state.project_id,
4266+ "generated_at": now_utc_iso(),
4267+ "graph_store": {
4268+ "backend": "twm_repository_state_graph",
4269+ "full_graph_persisted": True,
4270+ "node_tables": ["twm_state_object", "twm_rule_hit", "twm_evidence_item", "twm_review_task"],
4271+ "edge_tables": ["twm_state_relation", "derived_rule_support_review_edges"],
4272+ "production_policy": "full state graph is persisted; browser visualization may render a focus subset for usability",
4273+ },
4274+ "full_graph_counts": full_counts,
4275+ "object_counts_by_role": object_counts,
4276+ "relation_counts_by_type": relation_counts,
4277+ "support_material_counts_by_type": support_counts,
4278+ "visual_graph": visual_graph,
4279+ "full_graph": {
4280+ "included": include_full_graph,
4281+ "nodes": nodes if include_full_graph else [],
4282+ "edges": edges if include_full_graph else [],
4283+ },
4284+ "terminology": {
4285+ "support_material": "界面和汇报中使用“支撑材料/判断依据”,后端兼容字段仍可能叫 evidence。",
4286+ "simulator": "状态 + 动作 -> 下一状态摘要、约束风险、收益和可信度。",
4287+ "planner": "在模拟器评价的动作或候选方案中,按约束、收益、风险和支撑材料完整度选择下一步。",
4288+ },
4289+ }
4290+
4291+ def _state_graph_visual_subset(
4292+ self,
4293+ nodes: list[dict[str, Any]],
4294+ edges: list[dict[str, Any]],
4295+ *,
4296+ visual_limit: int,
4297+ focus_node_id: str = "",
4298+ ) -> dict[str, Any]:
4299+ node_by_id = {compact_text(node.get("id")): node for node in nodes}
4300+ selected: list[str] = []
4301+ selected_set: set[str] = set()
4302+
4303+ def select(node_id: str) -> None:
4304+ if not node_id or node_id in selected_set or node_id not in node_by_id:
4305+ return
4306+ if len(selected) >= visual_limit:
4307+ return
4308+ selected_set.add(node_id)
4309+ selected.append(node_id)
4310+
4311+ if focus_node_id:
4312+ select(focus_node_id)
4313+ for edge in edges:
4314+ source = compact_text(edge.get("source"))
4315+ target = compact_text(edge.get("target"))
4316+ if source == focus_node_id:
4317+ select(target)
4318+ elif target == focus_node_id:
4319+ select(source)
4320+ if len(selected) >= visual_limit:
4321+ break
4322+
4323+ severity_rank = {"blocking": 5, "critical": 4, "high": 3, "medium": 2, "low": 1}
4324+ ranked_nodes = sorted(
4325+ nodes,
4326+ key=lambda node: (
4327+ 0 if compact_text(node.get("id")) in selected_set else 1,
4328+ -severity_rank.get(compact_text(node.get("severity")), 0),
4329+ 0 if node.get("kind") == "rule_hit" else 1,
4330+ 0 if node.get("kind") == "state_object" and node.get("role") == "project" else 1,
4331+ compact_text(node.get("label")),
4332+ ),
4333+ )
4334+ for node in ranked_nodes:
4335+ select(compact_text(node.get("id")))
4336+ if len(selected) >= visual_limit:
4337+ break
4338+
4339+ visual_nodes = [node_by_id[node_id] for node_id in selected]
4340+ visual_node_ids = set(selected)
4341+ visual_edges = [
4342+ edge for edge in edges
4343+ if compact_text(edge.get("source")) in visual_node_ids and compact_text(edge.get("target")) in visual_node_ids
4344+ ]
4345+ return {
4346+ "nodes": visual_nodes,
4347+ "edges": visual_edges,
4348+ "render_policy": {
4349+ "visual_node_limit": visual_limit,
4350+ "rendered_node_count": len(visual_nodes),
4351+ "rendered_edge_count": len(visual_edges),
4352+ "full_graph_node_count": len(nodes),
4353+ "full_graph_edge_count": len(edges),
4354+ "visual_subset_only": len(visual_nodes) < len(nodes),
4355+ "full_graph_counts_available": True,
4356+ "focus_node_id": focus_node_id,
4357+ },
4358+ }
4359+
4360+ def _state_graph_object_role(self, obj: TwmStateObject) -> str:
4361+ return compact_text(obj.canonical_role or obj.source_role or obj.object_type or "unknown")
4362+
4363+ def _state_graph_object_node(self, obj: TwmStateObject) -> dict[str, Any]:
4364+ attrs = dict(obj.attributes or {})
4365+ label = (
4366+ compact_text(attrs.get("XMMC"))
4367+ or compact_text(attrs.get("project_name"))
4368+ or compact_text(attrs.get("name"))
4369+ or compact_text(attrs.get("DLMC"))
4370+ or compact_text(attrs.get("zone_type"))
4371+ or compact_text(obj.object_code)
4372+ or compact_text(obj.source_feature_id)
4373+ or obj.id
4374+ )
4375+ role = self._state_graph_object_role(obj)
4376+ return {
4377+ "id": obj.id,
4378+ "kind": "state_object",
4379+ "role": role,
4380+ "label": label,
4381+ "object_code": obj.object_code,
4382+ "source_role": obj.source_role,
4383+ "source_path": obj.source_path,
4384+ "bbox": jsonable(obj.bbox),
4385+ "quality_score": obj.quality_score,
4386+ "synthetic": obj.synthetic,
4387+ "not_for_production": obj.not_for_production,
4388+ "map_stage": self._state_graph_map_stage_for_role(role),
4389+ "summary": self._state_graph_compact_attributes(attrs),
4390+ }
4391+
4392+ def _state_graph_rule_hit_node(self, hit: TwmRuleHit) -> dict[str, Any]:
4393+ return {
4394+ "id": f"rule_hit:{hit.id}",
4395+ "kind": "rule_hit",
4396+ "role": "rule_hit",
4397+ "label": compact_text(hit.rule_id or "规则判断"),
4398+ "severity": hit.severity,
4399+ "risk_score": hit.risk_score,
4400+ "status": hit.hit_status,
4401+ "summary": compact_text(hit.explanation),
4402+ "map_stage": "risk",
4403+ }
4404+
4405+ def _state_graph_support_material_node(self, item: TwmEvidenceItem) -> dict[str, Any]:
4406+ payload = dict(item.payload or {})
4407+ return {
4408+ "id": f"support:{item.id}",
4409+ "kind": "support_material",
4410+ "role": "support_material",
4411+ "label": compact_text(item.source_ref or item.evidence_type or "支撑材料"),
4412+ "support_type": item.evidence_type,
4413+ "source_system": item.source_system,
4414+ "source_ref": item.source_ref,
4415+ "has_checksum": bool(item.checksum),
4416+ "summary": self._state_graph_compact_attributes(payload),
4417+ "map_stage": "risk",
4418+ }
4419+
4420+ def _state_graph_review_task_node(self, task: TwmReviewTask) -> dict[str, Any]:
4421+ return {
4422+ "id": f"review:{task.id}",
4423+ "kind": "review_task",
4424+ "role": "review_task",
4425+ "label": compact_text(task.decision or task.status or "人工复核"),
4426+ "status": task.status,
4427+ "summary": compact_text(task.comment),
4428+ "map_stage": "risk",
4429+ }
4430+
4431+ def _state_graph_compact_attributes(self, attrs: dict[str, Any], limit: int = 5) -> dict[str, Any]:
4432+ priority = [
4433+ "XMMC",
4434+ "project_name",
4435+ "YDMJ",
4436+ "approval_status",
4437+ "risk_scenario",
4438+ "DLMC",
4439+ "TDYTMC",
4440+ "zone_type",
4441+ "source_layer",
4442+ "overlap_area_m2",
4443+ ]
4444+ result: dict[str, Any] = {}
4445+ for key in priority:
4446+ if key in attrs and len(result) < limit:
4447+ result[key] = jsonable(attrs[key])
4448+ for key, value in attrs.items():
4449+ if len(result) >= limit:
4450+ break
4451+ if key not in result:
4452+ result[str(key)] = jsonable(value)
4453+ return result
4454+
4455+ def _state_graph_map_stage_for_role(self, role: str) -> str:
4456+ normalized = role.lower()
4457+ if "candidate" in normalized or "scenario" in normalized:
4458+ return "plan"
4459+ if "rule" in normalized or "review" in normalized:
4460+ return "risk"
4461+ if "farmland" in normalized or "eco" in normalized or "constraint" in normalized:
4462+ return "risk"
4463+ return "locate"
4464+
41364465 def state_snapshot_lakehouse_manifest(self, state_version_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
41374466 payload = dict(payload or {})
41384467 state = self.repository.get_state_version(state_version_id)
0 commit comments