Skip to content

Commit 2227f18

Browse files
committed
Add TWM state graph demo workflow
1 parent fb52cd9 commit 2227f18

8 files changed

Lines changed: 1595 additions & 131 deletions

File tree

data_agent/api/territory_world_model_routes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,27 @@ async def twm_state_detail(request: Request):
372372
return JSONResponse(state)
373373

374374

375+
async def twm_state_graph(request: Request):
376+
user = _get_user_from_request(request)
377+
if not user:
378+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
379+
_set_user_context(user)
380+
svc = get_territory_world_model_service()
381+
payload = {
382+
"include_full_graph": request.query_params.get("include_full_graph", "false"),
383+
"visual_node_limit": request.query_params.get("visual_node_limit", "160"),
384+
"focus_object_id": request.query_params.get("focus_object_id", ""),
385+
"focus_node_id": request.query_params.get("focus_node_id", ""),
386+
}
387+
try:
388+
result = await asyncio.to_thread(svc.state_graph_report, request.path_params["id"], payload)
389+
return JSONResponse(result)
390+
except LookupError as exc:
391+
return JSONResponse({"error": str(exc)}, status_code=404)
392+
except Exception as exc:
393+
return JSONResponse({"error": str(exc)}, status_code=400)
394+
395+
375396
async def twm_evaluate_rules(request: Request):
376397
user = _get_user_from_request(request)
377398
if not user:
@@ -1446,6 +1467,7 @@ def get_territory_world_model_routes() -> list[Route]:
14461467
Route("/api/twm/projects/{id}/states", endpoint=twm_project_states, methods=["GET"]),
14471468
Route("/api/twm/projects/{id}/build-state", endpoint=twm_build_state, methods=["POST"]),
14481469
Route("/api/twm/states/{id}", endpoint=twm_state_detail, methods=["GET"]),
1470+
Route("/api/twm/states/{id}/state-graph", endpoint=twm_state_graph, methods=["GET"]),
14491471
Route("/api/twm/states/{id}/evaluate-rules", endpoint=twm_evaluate_rules, methods=["POST"]),
14501472
Route("/api/twm/states/{id}/rule-hits", endpoint=twm_rule_hits, methods=["GET"]),
14511473
Route("/api/twm/rule-hits/{id}", endpoint=twm_rule_hit_detail, methods=["GET"]),

data_agent/territory_world_model/service.py

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

data_agent/test_frontend_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ def test_route_paths(self):
416416
self.assertIn("/api/twm/baseline-comparison-report", paths)
417417
self.assertIn("/api/twm/data-foundation-assessment", paths)
418418
self.assertIn("/api/twm/states/{id}/forecast", paths)
419+
self.assertIn("/api/twm/states/{id}/state-graph", paths)
419420

420421
def test_mount_before_catchall(self):
421422
from data_agent.frontend_api import mount_frontend_api

0 commit comments

Comments
 (0)