ckgraphify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1420 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from graphify.graph_main_trace import trace_graph_main
9
+
10
+
11
+ _STATUS_ORDER = {"verified": 0, "candidate": 1, "partial": 2, "unexplored": 3, "stale": 4}
12
+ _KG_BANKS_DIR_NAME = "kg-banks"
13
+ _KG_BANKS_ROOT_MARKER = "__root__"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BusinessValidationResult:
18
+ concept_count: int
19
+ scenario_count: int
20
+ anchor_count: int
21
+ trace_hint_count: int
22
+ missing_anchors: list[dict]
23
+ missing_trace_refs: list[dict]
24
+
25
+ @property
26
+ def ok(self) -> bool:
27
+ return not self.missing_anchors and not self.missing_trace_refs
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class BusinessLintIssue:
32
+ level: str
33
+ path: str
34
+ message: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BusinessLintResult:
39
+ issues: list[BusinessLintIssue]
40
+
41
+ @property
42
+ def errors(self) -> list[BusinessLintIssue]:
43
+ return [issue for issue in self.issues if issue.level == "ERROR"]
44
+
45
+ @property
46
+ def warnings(self) -> list[BusinessLintIssue]:
47
+ return [issue for issue in self.issues if issue.level == "WARN"]
48
+
49
+ @property
50
+ def ok(self) -> bool:
51
+ return not self.errors
52
+
53
+
54
+ def health_card_seed() -> dict:
55
+ return {
56
+ "version": 1,
57
+ "kind": "business-map",
58
+ "description": "Business concept map layered on top of graph-main facts.",
59
+ "concepts": [
60
+ {
61
+ "id": "health_card",
62
+ "name": "健康卡",
63
+ "aliases": ["healthCard", "health card", "健康权益卡", "健康卡运营", "健康卡商品"],
64
+ "status": "partial",
65
+ "summary": (
66
+ "当前已探索运营端配置和 C 端商品透出。落地页、购买、退卡、权益、商品推荐等链路仍待补充。"
67
+ ),
68
+ "scenarios": [
69
+ {
70
+ "id": "health_card.admin_config",
71
+ "name": "运营端配置",
72
+ "status": "verified",
73
+ "summary": "运营前端通过健康卡 REST 接口进入后端 controller,承载配置创建、查询等管理能力。",
74
+ "anchors": [
75
+ {
76
+ "kind": "page",
77
+ "ref": "ehealth-member",
78
+ "role": "运营前端入口",
79
+ "confidence": "verified",
80
+ },
81
+ {
82
+ "kind": "rest_api",
83
+ "ref": "/shopping/healthCard/createHealthCard",
84
+ "role": "创建健康卡 REST 接口",
85
+ "confidence": "verified",
86
+ },
87
+ ],
88
+ "trace_hints": [
89
+ {
90
+ "from": "ehealth-member",
91
+ "api": "/shopping/healthCard/createHealthCard",
92
+ "max_depth": 8,
93
+ }
94
+ ],
95
+ "gaps": [],
96
+ },
97
+ {
98
+ "id": "health_card.goods_exposure",
99
+ "name": "C端商品透出",
100
+ "status": "verified",
101
+ "summary": (
102
+ "店铺页和商详页经 Venus 商品 MTop/HSF 进入商品资源聚合,再向 SummaryX 和 health-client 扩展。"
103
+ ),
104
+ "anchors": [
105
+ {"kind": "page", "ref": "shop", "role": "店铺页入口", "confidence": "verified"},
106
+ {"kind": "page", "ref": "ugoodsinfo", "role": "商详页入口", "confidence": "verified"},
107
+ {
108
+ "kind": "mtop_api",
109
+ "ref": "mtop.venus.ShopCategoryService.getCategoryDetail",
110
+ "role": "店铺分类/商品列表入口",
111
+ "confidence": "verified",
112
+ },
113
+ {
114
+ "kind": "mtop_api",
115
+ "ref": "mtop.venus.ShopGoodsService.getShopGoodsResource",
116
+ "role": "商详商品资源入口",
117
+ "confidence": "verified",
118
+ },
119
+ {
120
+ "kind": "mtop_api",
121
+ "ref": "mtop.venus.ShopGoodsService.getShopGoodsDetail",
122
+ "role": "商详商品详情入口",
123
+ "confidence": "verified",
124
+ },
125
+ {
126
+ "kind": "hsf_method",
127
+ "ref": "me.ele.newretail.venus.client.api.ShopGoodsService#getShopGoodsResource",
128
+ "role": "Venus 商品资源聚合方法",
129
+ "confidence": "verified",
130
+ },
131
+ {
132
+ "kind": "hsf_method",
133
+ "ref": "me.ele.newretail.summaryx.newsdk.sdk.ItemServiceSummaryXClient#itemRendering",
134
+ "role": "SummaryX 商品渲染方法",
135
+ "confidence": "verified",
136
+ },
137
+ {
138
+ "kind": "hsf_method",
139
+ "ref": "me.ele.newretail.health.client.HealthMedicareTagQueryClient#matchMedicareTag",
140
+ "role": "health-client 医保标签补全",
141
+ "confidence": "verified",
142
+ },
143
+ {
144
+ "kind": "hsf_method",
145
+ "ref": "me.ele.newretail.health.client.HealthRxComplianceQueryClient#queryRxCompliance",
146
+ "role": "health-client 处方/合规信息补全",
147
+ "confidence": "verified",
148
+ },
149
+ ],
150
+ "trace_hints": [
151
+ {
152
+ "from": "shop",
153
+ "api": "mtop.venus.ShopCategoryService.getCategoryDetail",
154
+ "max_depth": 10,
155
+ },
156
+ {
157
+ "from": "ugoodsinfo",
158
+ "api": "mtop.venus.ShopGoodsService.getShopGoodsResource",
159
+ "max_depth": 10,
160
+ },
161
+ {
162
+ "from": "ugoodsinfo",
163
+ "api": "mtop.venus.ShopGoodsService.getShopGoodsDetail",
164
+ "max_depth": 10,
165
+ },
166
+ ],
167
+ "gaps": [
168
+ "健康卡商品筛选条件、推荐策略和落地页承接关系仍需继续探索。",
169
+ ],
170
+ },
171
+ {
172
+ "id": "health_card.landing_page",
173
+ "name": "落地页",
174
+ "status": "unexplored",
175
+ "summary": "待探索健康卡落地页入口、前端组件、MTop/REST 和后端承接链路。",
176
+ "anchors": [],
177
+ "trace_hints": [],
178
+ "gaps": ["缺少入口 repo、页面和 API 锚点。"],
179
+ },
180
+ {
181
+ "id": "health_card.purchase",
182
+ "name": "购买",
183
+ "status": "unexplored",
184
+ "summary": "待探索健康卡购买、交易、权益发放和订单链路。",
185
+ "anchors": [],
186
+ "trace_hints": [],
187
+ "gaps": ["缺少交易和权益发放相关锚点。"],
188
+ },
189
+ {
190
+ "id": "health_card.refund",
191
+ "name": "退卡",
192
+ "status": "unexplored",
193
+ "summary": "待探索退卡、退款、售后和权益回收链路。",
194
+ "anchors": [],
195
+ "trace_hints": [],
196
+ "gaps": ["缺少售后/退款相关锚点。"],
197
+ },
198
+ {
199
+ "id": "health_card.benefits",
200
+ "name": "权益",
201
+ "status": "unexplored",
202
+ "summary": "待探索健康卡权益查询、展示、核销和履约链路。",
203
+ "anchors": [],
204
+ "trace_hints": [],
205
+ "gaps": ["缺少权益服务和履约相关锚点。"],
206
+ },
207
+ {
208
+ "id": "health_card.recommendation",
209
+ "name": "商品推荐",
210
+ "status": "unexplored",
211
+ "summary": "待探索健康卡商品推荐、召回、排序和健康商品规则链路。",
212
+ "anchors": [],
213
+ "trace_hints": [],
214
+ "gaps": ["缺少推荐入口和召回服务锚点。"],
215
+ },
216
+ ],
217
+ }
218
+ ],
219
+ }
220
+
221
+
222
+ def load_business_map(path: Path) -> dict:
223
+ data = json.loads(path.read_text(encoding="utf-8"))
224
+ if not isinstance(data, dict):
225
+ raise ValueError(f"business map must be a JSON object: {path}")
226
+ data.setdefault("concepts", [])
227
+ return data
228
+
229
+
230
+ def write_business_map(data: dict, path: Path) -> None:
231
+ path.parent.mkdir(parents=True, exist_ok=True)
232
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
233
+
234
+
235
+ def is_kg_banks_root(path: Path) -> bool:
236
+ """Return True only for a marked kg-banks root, not any directory with that name."""
237
+ path = Path(path)
238
+ if path.name != _KG_BANKS_DIR_NAME:
239
+ return False
240
+ return (path / _KG_BANKS_ROOT_MARKER).exists() or (path / "graphify-out" / "business-map.json").is_file()
241
+
242
+
243
+ def find_kg_banks_root(start: Path | None = None) -> Path | None:
244
+ """Find the marked kg-banks root from kg-banks itself or from its parent."""
245
+ start_path = Path.cwd() if start is None else Path(start)
246
+ start_path = start_path.resolve()
247
+ search_paths = [start_path, *start_path.parents]
248
+ for base in search_paths:
249
+ if is_kg_banks_root(base):
250
+ return base
251
+ child = base / _KG_BANKS_DIR_NAME
252
+ if child.is_dir() and is_kg_banks_root(child):
253
+ return child
254
+ return None
255
+
256
+
257
+ def default_business_map_path(start: Path | None = None) -> Path:
258
+ root = find_kg_banks_root(start)
259
+ if root is not None:
260
+ return root / "graphify-out" / "business-map.json"
261
+ return Path("graphify-out") / "business-map.json"
262
+
263
+
264
+ def _business_map_graph_ref(data: dict) -> str:
265
+ graph_meta = data.get("graph")
266
+ if isinstance(graph_meta, dict):
267
+ for key in ("path", "file", "graph_path"):
268
+ value = graph_meta.get(key)
269
+ if value:
270
+ return str(value)
271
+ for key in ("graph_path", "graph_file"):
272
+ value = data.get(key)
273
+ if value:
274
+ return str(value)
275
+ if isinstance(graph_meta, str):
276
+ return graph_meta
277
+ return ""
278
+
279
+
280
+ def resolve_bound_graph_path(map_path: Path, graph_ref: str) -> Path:
281
+ raw = Path(graph_ref).expanduser()
282
+ if raw.is_absolute():
283
+ return raw
284
+ map_path = Path(map_path)
285
+ candidates = [
286
+ Path.cwd() / raw,
287
+ map_path.parent / raw,
288
+ map_path.parent.parent / raw,
289
+ ]
290
+ for candidate in candidates:
291
+ if candidate.exists():
292
+ return candidate
293
+ return candidates[1]
294
+
295
+
296
+ def default_business_graph_path(map_path: Path, start: Path | None = None) -> Path | None:
297
+ if Path(map_path).exists():
298
+ graph_ref = _business_map_graph_ref(load_business_map(Path(map_path)))
299
+ if graph_ref:
300
+ return resolve_bound_graph_path(Path(map_path), graph_ref)
301
+
302
+ root = find_kg_banks_root(start)
303
+ graphify_out = (root / "graphify-out") if root is not None else Path("graphify-out")
304
+ candidates = sorted(
305
+ p for p in graphify_out.glob("graph*.json")
306
+ if p.name != "business-map.json"
307
+ )
308
+ if not candidates:
309
+ fallback = graphify_out / "graph-main-merged.json"
310
+ return fallback if fallback.exists() else None
311
+ for candidate in candidates:
312
+ if candidate.name == "graph-main-merged.json":
313
+ return candidate
314
+ return candidates[0]
315
+
316
+
317
+ def init_business_map(concept: str, out_path: Path, *, force: bool = False) -> dict:
318
+ if out_path.exists() and not force:
319
+ raise FileExistsError(f"business map already exists: {out_path}")
320
+ if not _matches_text(concept, "健康卡", ["health_card", "healthCard", "health card"]):
321
+ raise ValueError(f"unsupported seed concept: {concept}. Currently supported: 健康卡")
322
+ data = health_card_seed()
323
+ write_business_map(data, out_path)
324
+ return data
325
+
326
+
327
+ def _norm(v: object) -> str:
328
+ return str(v or "").strip().lower()
329
+
330
+
331
+ def _compact_ws(v: object) -> str:
332
+ return re.sub(r"\s+", " ", str(v or "").strip())
333
+
334
+
335
+ def _ascii_terms(text: str) -> list[str]:
336
+ pieces: list[str] = []
337
+ for raw in re.findall(r"[A-Za-z0-9_./#:-]+", text):
338
+ raw = raw.replace("_", " ").replace("/", " ").replace(":", " ")
339
+ raw = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", raw)
340
+ pieces.extend(part.lower() for part in re.split(r"[^A-Za-z0-9#.-]+", raw) if part)
341
+ return pieces
342
+
343
+
344
+ def _cjk_terms(text: str) -> list[str]:
345
+ terms: list[str] = []
346
+ for block in re.findall(r"[\u4e00-\u9fff]+", text):
347
+ terms.extend(ch for ch in block if ch.strip())
348
+ terms.extend(block[i : i + 2] for i in range(max(0, len(block) - 1)))
349
+ return terms
350
+
351
+
352
+ def _query_terms(text: str) -> set[str]:
353
+ stop = {"相关", "链路", "流程", "场景", "查询", "查看", "一下", "怎么", "如何"}
354
+ terms = set(_ascii_terms(text) + _cjk_terms(text))
355
+ return {term for term in terms if term and term not in stop}
356
+
357
+
358
+ def _matches_text(query: str, name: str, aliases: list[str] | None = None) -> bool:
359
+ q = _norm(query)
360
+ values = [_norm(name), *[_norm(a) for a in aliases or []]]
361
+ return any(q == v or (q and q in v) or (v and v in q) for v in values)
362
+
363
+
364
+ def _concept_matches(concept: dict, query: str) -> bool:
365
+ return _matches_text(query, str(concept.get("name", "")), [str(concept.get("id", "")), *concept.get("aliases", [])])
366
+
367
+
368
+ def _scenario_matches(scenario: dict, query: str) -> bool:
369
+ return _matches_text(query, str(scenario.get("name", "")), [str(scenario.get("id", ""))])
370
+
371
+
372
+ def find_concept(data: dict, query: str) -> dict:
373
+ matches = [c for c in data.get("concepts", []) if isinstance(c, dict) and _concept_matches(c, query)]
374
+ if not matches:
375
+ raise ValueError(f"no business concept matched: {query}")
376
+ return sorted(matches, key=lambda c: _STATUS_ORDER.get(str(c.get("status", "")), 99))[0]
377
+
378
+
379
+ def find_scenarios(concept: dict, query: str = "") -> list[dict]:
380
+ scenarios = [s for s in concept.get("scenarios", []) if isinstance(s, dict)]
381
+ if not query:
382
+ return sorted(scenarios, key=lambda s: (_STATUS_ORDER.get(str(s.get("status", "")), 99), str(s.get("id", ""))))
383
+ matches = [s for s in scenarios if _scenario_matches(s, query)]
384
+ if not matches:
385
+ raise ValueError(f"no scenario matched '{query}' in concept {concept.get('name', concept.get('id'))}")
386
+ return sorted(matches, key=lambda s: (_STATUS_ORDER.get(str(s.get("status", "")), 99), str(s.get("id", ""))))
387
+
388
+
389
+ def _flow_matches(flow: dict, query: str) -> bool:
390
+ return _matches_text(query, str(flow.get("name", "")), [str(flow.get("id", ""))])
391
+
392
+
393
+ def find_flow(scenario: dict, query: str = "") -> dict | None:
394
+ if not query:
395
+ return None
396
+ matches = [flow for flow in _scenario_flows(scenario) if _flow_matches(flow, query)]
397
+ if not matches:
398
+ raise ValueError(f"no flow matched '{query}' in scenario {scenario.get('name', scenario.get('id'))}")
399
+ return sorted(matches, key=lambda f: (_STATUS_ORDER.get(str(f.get("status", "")), 99), str(f.get("id", ""))))[0]
400
+
401
+
402
+ def _scenario_flows(scenario: dict) -> list[dict]:
403
+ return [flow for flow in scenario.get("flows", []) if isinstance(flow, dict)]
404
+
405
+
406
+ def _format_business_path(concept: dict, scenario: dict | None = None, flow: dict | None = None) -> str:
407
+ parts = [str(concept.get("id", concept.get("name", "<concept>")))]
408
+ if scenario is not None:
409
+ parts.append(str(scenario.get("id", scenario.get("name", "<scenario>"))))
410
+ if flow is not None:
411
+ parts.append(str(flow.get("id", flow.get("name", "<flow>"))))
412
+ return " / ".join(parts)
413
+
414
+
415
+ def _scenario_anchors(scenario: dict) -> list[tuple[dict, str]]:
416
+ items: list[tuple[dict, str]] = []
417
+ for anchor in scenario.get("anchors", []):
418
+ if isinstance(anchor, dict):
419
+ items.append((anchor, ""))
420
+ for flow in _scenario_flows(scenario):
421
+ flow_id = str(flow.get("id", flow.get("name", "")))
422
+ for anchor in flow.get("anchors", []):
423
+ if isinstance(anchor, dict):
424
+ items.append((anchor, flow_id))
425
+ return items
426
+
427
+
428
+ def _scenario_trace_hints(scenario: dict) -> list[tuple[dict, str]]:
429
+ items: list[tuple[dict, str]] = []
430
+ for hint in scenario.get("trace_hints", []):
431
+ if isinstance(hint, dict):
432
+ items.append((hint, ""))
433
+ for flow in _scenario_flows(scenario):
434
+ flow_id = str(flow.get("id", flow.get("name", "")))
435
+ for hint in flow.get("trace_hints", []):
436
+ if isinstance(hint, dict):
437
+ items.append((hint, flow_id))
438
+ return items
439
+
440
+
441
+ def _list_constraint_values(value: object) -> list[str]:
442
+ if isinstance(value, str):
443
+ return [item.strip() for item in value.split(",") if item.strip()]
444
+ if isinstance(value, list):
445
+ return [str(item).strip() for item in value if str(item).strip()]
446
+ return []
447
+
448
+
449
+ def _merge_search_constraints(*owners: dict) -> dict:
450
+ merged = {"prefer_repos": [], "exclude_repos": []}
451
+ for owner in owners:
452
+ constraints = owner.get("search_constraints", {}) if isinstance(owner, dict) else {}
453
+ if not isinstance(constraints, dict):
454
+ continue
455
+ for key in ("prefer_repos", "exclude_repos"):
456
+ for item in _list_constraint_values(constraints.get(key)):
457
+ if item not in merged[key]:
458
+ merged[key].append(item)
459
+ return merged
460
+
461
+
462
+ def _scenario_trace_items(scenario: dict, flow_query: str = "") -> list[tuple[dict, str, dict]]:
463
+ items: list[tuple[dict, str, dict]] = []
464
+ scenario_constraints = _merge_search_constraints(scenario)
465
+ if not flow_query:
466
+ for hint in scenario.get("trace_hints", []):
467
+ if isinstance(hint, dict):
468
+ items.append((hint, "", _merge_search_constraints({"search_constraints": scenario_constraints}, hint)))
469
+ for flow in _scenario_flows(scenario):
470
+ flow_id = str(flow.get("id", flow.get("name", "")))
471
+ if flow_query and not _flow_matches(flow, flow_query):
472
+ continue
473
+ flow_constraints = _merge_search_constraints(scenario, flow)
474
+ for hint in flow.get("trace_hints", []):
475
+ if isinstance(hint, dict):
476
+ items.append((hint, flow_id, _merge_search_constraints({"search_constraints": flow_constraints}, hint)))
477
+ return items
478
+
479
+
480
+ def _scenario_gaps(scenario: dict) -> list[str]:
481
+ gaps = [str(g) for g in scenario.get("gaps", []) if g]
482
+ for flow in _scenario_flows(scenario):
483
+ flow_name = str(flow.get("name", flow.get("id", "")))
484
+ for gap in flow.get("gaps", []):
485
+ if gap:
486
+ gaps.append(f"{flow_name}: {gap}" if flow_name else str(gap))
487
+ return gaps
488
+
489
+
490
+ def _format_accepted_gap(gap: object) -> str:
491
+ if isinstance(gap, dict):
492
+ parts = []
493
+ gap_id = str(gap.get("id", "")).strip()
494
+ gap_type = str(gap.get("type", "")).strip()
495
+ status = str(gap.get("status", "")).strip()
496
+ summary = str(gap.get("summary", "")).strip()
497
+ if gap_id:
498
+ parts.append(gap_id)
499
+ if gap_type:
500
+ parts.append(f"type={gap_type}")
501
+ if status:
502
+ parts.append(f"status={status}")
503
+ if summary:
504
+ parts.append(summary)
505
+ return " ".join(parts)
506
+ return str(gap)
507
+
508
+
509
+ def _accepted_gap_summary(gap: object) -> str:
510
+ if isinstance(gap, dict):
511
+ return str(gap.get("summary", ""))
512
+ return str(gap)
513
+
514
+
515
+ def _append_anchor_text(parts: list[str], anchors: list[dict]) -> None:
516
+ for anchor in anchors:
517
+ if not isinstance(anchor, dict):
518
+ continue
519
+ parts.extend(
520
+ [
521
+ str(anchor.get("kind", "")),
522
+ str(anchor.get("ref", "")),
523
+ str(anchor.get("role", "")),
524
+ str(anchor.get("confidence", "")),
525
+ ]
526
+ )
527
+
528
+
529
+ def _append_trace_hint_text(parts: list[str], hints: list[dict]) -> None:
530
+ for hint in hints:
531
+ if not isinstance(hint, dict):
532
+ continue
533
+ parts.extend([str(hint.get("from", "")), str(hint.get("api", ""))])
534
+
535
+
536
+ def _append_gap_text(parts: list[str], owner: dict) -> None:
537
+ for gap in owner.get("gaps", []):
538
+ parts.append(str(gap))
539
+ for gap in owner.get("accepted_gaps", []):
540
+ if isinstance(gap, dict):
541
+ parts.extend([str(gap.get("summary", "")), str(gap.get("reason", "")), str(gap.get("stop_rule", ""))])
542
+ else:
543
+ parts.append(str(gap))
544
+
545
+
546
+ def _constraints_text(owner: dict) -> list[str]:
547
+ constraints = _merge_search_constraints(owner)
548
+ return [*constraints["prefer_repos"], *constraints["exclude_repos"]]
549
+
550
+
551
+ def _business_query_docs(data: dict) -> list[dict]:
552
+ docs: list[dict] = []
553
+ for concept in data.get("concepts", []):
554
+ if not isinstance(concept, dict):
555
+ continue
556
+ concept_parts = [
557
+ str(concept.get("id", "")),
558
+ str(concept.get("name", "")),
559
+ *[str(a) for a in concept.get("aliases", [])],
560
+ str(concept.get("summary", "")),
561
+ str(concept.get("status", "")),
562
+ ]
563
+ concept_text = " ".join(_compact_ws(part) for part in concept_parts if _compact_ws(part))
564
+ docs.append(
565
+ {
566
+ "type": "concept",
567
+ "concept": concept,
568
+ "scenario": None,
569
+ "flow": None,
570
+ "path": str(concept.get("id", concept.get("name", ""))),
571
+ "text": concept_text,
572
+ "primary": " ".join(str(v) for v in [concept.get("id", ""), concept.get("name", ""), *concept.get("aliases", [])]),
573
+ "status": str(concept.get("status", "")),
574
+ }
575
+ )
576
+ for scenario in concept.get("scenarios", []):
577
+ if not isinstance(scenario, dict):
578
+ continue
579
+ scenario_parts = [
580
+ concept_text,
581
+ str(scenario.get("id", "")),
582
+ str(scenario.get("name", "")),
583
+ str(scenario.get("status", "")),
584
+ str(scenario.get("summary", "")),
585
+ str(scenario.get("verification_scope", "")),
586
+ *_constraints_text(scenario),
587
+ ]
588
+ _append_anchor_text(scenario_parts, [a for a, flow_id in _scenario_anchors(scenario) if not flow_id])
589
+ _append_trace_hint_text(scenario_parts, [h for h, flow_id in _scenario_trace_hints(scenario) if not flow_id])
590
+ _append_gap_text(scenario_parts, scenario)
591
+ docs.append(
592
+ {
593
+ "type": "scenario",
594
+ "concept": concept,
595
+ "scenario": scenario,
596
+ "flow": None,
597
+ "path": f"{concept.get('id', concept.get('name', ''))}/{scenario.get('id', scenario.get('name', ''))}",
598
+ "text": " ".join(_compact_ws(part) for part in scenario_parts if _compact_ws(part)),
599
+ "primary": " ".join(str(v) for v in [scenario.get("id", ""), scenario.get("name", "")]),
600
+ "status": str(scenario.get("status", "")),
601
+ }
602
+ )
603
+ for flow in _scenario_flows(scenario):
604
+ flow_parts = [
605
+ concept_text,
606
+ str(scenario.get("id", "")),
607
+ str(scenario.get("name", "")),
608
+ str(scenario.get("summary", "")),
609
+ str(flow.get("id", "")),
610
+ str(flow.get("name", "")),
611
+ str(flow.get("status", scenario.get("status", ""))),
612
+ str(flow.get("summary", "")),
613
+ str(flow.get("verification_scope", "")),
614
+ *_constraints_text(scenario),
615
+ *_constraints_text(flow),
616
+ ]
617
+ _append_anchor_text(flow_parts, [a for a in flow.get("anchors", []) if isinstance(a, dict)])
618
+ _append_trace_hint_text(flow_parts, [h for h in flow.get("trace_hints", []) if isinstance(h, dict)])
619
+ _append_gap_text(flow_parts, flow)
620
+ docs.append(
621
+ {
622
+ "type": "flow",
623
+ "concept": concept,
624
+ "scenario": scenario,
625
+ "flow": flow,
626
+ "path": (
627
+ f"{concept.get('id', concept.get('name', ''))}/"
628
+ f"{scenario.get('id', scenario.get('name', ''))}/"
629
+ f"{flow.get('id', flow.get('name', ''))}"
630
+ ),
631
+ "text": " ".join(_compact_ws(part) for part in flow_parts if _compact_ws(part)),
632
+ "primary": " ".join(str(v) for v in [flow.get("id", ""), flow.get("name", "")]),
633
+ "status": str(flow.get("status", scenario.get("status", ""))),
634
+ }
635
+ )
636
+ return docs
637
+
638
+
639
+ def _score_business_doc(query: str, doc: dict) -> tuple[int, list[str]]:
640
+ q = _norm(query)
641
+ text = _norm(doc.get("text", ""))
642
+ primary = _norm(doc.get("primary", ""))
643
+ q_terms = _query_terms(query)
644
+ text_terms = _query_terms(str(doc.get("text", "")))
645
+ primary_terms = _query_terms(str(doc.get("primary", "")))
646
+ hits = sorted(q_terms & text_terms)
647
+ score = 0
648
+ reasons: list[str] = []
649
+ if q and (q == primary or q in primary or primary in q):
650
+ score += 120
651
+ reasons.append("primary")
652
+ if q and q in text:
653
+ score += 80
654
+ reasons.append("phrase")
655
+ primary_hits = q_terms & primary_terms
656
+ if primary_hits:
657
+ score += 25 * len(primary_hits)
658
+ reasons.append("primary_terms:" + ",".join(sorted(primary_hits)[:8]))
659
+ if hits:
660
+ score += min(90, 8 * len(hits))
661
+ reasons.append("terms:" + ",".join(hits[:10]))
662
+ if not reasons:
663
+ return 0, []
664
+ if doc.get("type") == "flow":
665
+ score += 8
666
+ elif doc.get("type") == "scenario":
667
+ score += 4
668
+ status = str(doc.get("status", ""))
669
+ if status == "verified":
670
+ score += 5
671
+ elif status == "candidate":
672
+ score += 2
673
+ return score, reasons
674
+
675
+
676
+ def _summarize_business_doc(doc: dict, score: int, reasons: list[str]) -> dict:
677
+ concept = doc.get("concept") or {}
678
+ scenario = doc.get("scenario") or {}
679
+ flow = doc.get("flow") or {}
680
+ summary_owner = flow if flow else scenario if scenario else concept
681
+ trace_hints: list[dict] = []
682
+ if flow:
683
+ trace_hints = [h for h in flow.get("trace_hints", []) if isinstance(h, dict)]
684
+ elif scenario:
685
+ trace_hints = [h for h, _flow_id in _scenario_trace_hints(scenario)]
686
+ constraints = _merge_search_constraints(scenario, flow)
687
+ return {
688
+ "type": doc.get("type"),
689
+ "score": score,
690
+ "reasons": reasons,
691
+ "concept": concept.get("id") or concept.get("name"),
692
+ "concept_name": concept.get("name"),
693
+ "scenario": scenario.get("id") or scenario.get("name") if scenario else None,
694
+ "scenario_name": scenario.get("name") if scenario else None,
695
+ "flow": flow.get("id") or flow.get("name") if flow else None,
696
+ "flow_name": flow.get("name") if flow else None,
697
+ "status": summary_owner.get("status", doc.get("status", "")),
698
+ "summary": summary_owner.get("summary", ""),
699
+ "verification_scope": summary_owner.get("verification_scope", ""),
700
+ "gaps": _compact_gap_items([g for g in summary_owner.get("gaps", []) if g]),
701
+ "accepted_gaps": _compact_gap_items([g for g in summary_owner.get("accepted_gaps", []) if g]),
702
+ "trace_hint_count": len(trace_hints),
703
+ "search_constraints": constraints,
704
+ }
705
+
706
+
707
+ def _compact_gap_items(items: list[object], *, limit: int = 3) -> list[str]:
708
+ compact: list[str] = []
709
+ for item in items:
710
+ if isinstance(item, dict):
711
+ text = str(item.get("summary") or item.get("reason") or item.get("id") or "").strip()
712
+ else:
713
+ text = str(item or "").strip()
714
+ if text:
715
+ compact.append(text)
716
+ if len(compact) >= limit:
717
+ break
718
+ return compact
719
+
720
+
721
+ def _business_context(data: dict, query: str, *, limit: int = 4) -> list[dict]:
722
+ query_terms = _query_terms(query)
723
+ contexts: list[tuple[int, dict]] = []
724
+ for concept in data.get("concepts", []):
725
+ if not isinstance(concept, dict):
726
+ continue
727
+ concept_terms = _query_terms(
728
+ " ".join(
729
+ [
730
+ str(concept.get("id", "")),
731
+ str(concept.get("name", "")),
732
+ *[str(a) for a in concept.get("aliases", [])],
733
+ str(concept.get("summary", "")),
734
+ ]
735
+ )
736
+ )
737
+ concept_score = len(query_terms & concept_terms)
738
+ scenarios: list[dict] = []
739
+ for scenario in concept.get("scenarios", []):
740
+ if not isinstance(scenario, dict):
741
+ continue
742
+ scenario_terms = _query_terms(
743
+ " ".join(
744
+ [
745
+ str(scenario.get("id", "")),
746
+ str(scenario.get("name", "")),
747
+ str(scenario.get("summary", "")),
748
+ str(scenario.get("verification_scope", "")),
749
+ *[str(a.get("ref", "")) + " " + str(a.get("role", "")) for a, _flow in _scenario_anchors(scenario) if isinstance(a, dict)],
750
+ ]
751
+ )
752
+ )
753
+ scenario_score = len(query_terms & scenario_terms)
754
+ constraints = _merge_search_constraints(scenario)
755
+ scenarios.append(
756
+ {
757
+ "id": scenario.get("id"),
758
+ "name": scenario.get("name"),
759
+ "status": scenario.get("status"),
760
+ "score_hint": scenario_score,
761
+ "summary": scenario.get("summary", ""),
762
+ "search_constraints": constraints,
763
+ "gaps": _compact_gap_items([g for g in scenario.get("gaps", []) if g]),
764
+ "accepted_gaps": _compact_gap_items([g for g in scenario.get("accepted_gaps", []) if g]),
765
+ }
766
+ )
767
+ concept_score = max(concept_score, scenario_score)
768
+ scenarios.sort(key=lambda item: (int(item.get("score_hint", 0)), -_STATUS_ORDER.get(str(item.get("status", "")), 99), str(item.get("id", ""))), reverse=True)
769
+ contexts.append(
770
+ (
771
+ concept_score,
772
+ {
773
+ "concept": concept.get("id") or concept.get("name"),
774
+ "name": concept.get("name"),
775
+ "status": concept.get("status"),
776
+ "summary": concept.get("summary", ""),
777
+ "aliases": concept.get("aliases", []),
778
+ "top_scenarios": scenarios[:limit],
779
+ },
780
+ )
781
+ )
782
+ contexts.sort(key=lambda item: (item[0], str(item[1].get("concept", ""))), reverse=True)
783
+ return [context for _score, context in contexts[:limit]]
784
+
785
+
786
+ def match_business_query(data: dict, query: str, *, top_k: int = 5) -> dict:
787
+ scored: list[tuple[int, dict, list[str]]] = []
788
+ for doc in _business_query_docs(data):
789
+ score, reasons = _score_business_doc(query, doc)
790
+ if score > 0:
791
+ scored.append((score, doc, reasons))
792
+ scored.sort(
793
+ key=lambda item: (
794
+ item[0],
795
+ 2 if item[1].get("type") == "flow" else 1 if item[1].get("type") == "scenario" else 0,
796
+ -_STATUS_ORDER.get(str(item[1].get("status", "")), 99),
797
+ str(item[1].get("path", "")),
798
+ ),
799
+ reverse=True,
800
+ )
801
+ top_matches = [_summarize_business_doc(doc, score, reasons) for score, doc, reasons in scored[:top_k]]
802
+ specific_matches = [c for c in top_matches if c.get("type") in {"scenario", "flow"}]
803
+ best = specific_matches[0] if specific_matches else top_matches[0] if top_matches else None
804
+ if best:
805
+ top_matches = [best, *[match for match in top_matches if match is not best]]
806
+ return {
807
+ "status": "matched" if best else "none",
808
+ "scope": best.get("type") if best else None,
809
+ "best": best,
810
+ "top_matches": top_matches,
811
+ }
812
+
813
+
814
+ def _load_graph_nodes(graph_path: Path) -> list[dict]:
815
+ data = json.loads(graph_path.read_text(encoding="utf-8"))
816
+ return [n for n in data.get("nodes", []) if isinstance(n, dict)]
817
+
818
+
819
+ def search_graph_main_matches(graph_path: Path, query: str, *, top_k: int = 10) -> list[dict]:
820
+ preferred_kinds = {"page", "component", "mtop_api", "rest_api", "hsf_method"}
821
+ q = _norm(query)
822
+ q_terms = _query_terms(query)
823
+ results: list[tuple[int, dict, list[str]]] = []
824
+ for node in _load_graph_nodes(graph_path):
825
+ text_parts = [
826
+ str(node.get("id", "")),
827
+ str(node.get("label", "")),
828
+ str(node.get("canonical_key", "")),
829
+ str(node.get("repo", "")),
830
+ str(node.get("source_file", "")),
831
+ str(node.get("main_kind", "")),
832
+ ]
833
+ text = _norm(" ".join(text_parts))
834
+ terms = _query_terms(" ".join(text_parts))
835
+ hits = sorted(q_terms & terms)
836
+ score = 0
837
+ reasons: list[str] = []
838
+ if q and q in text:
839
+ score += 80
840
+ reasons.append("phrase")
841
+ if hits:
842
+ score += min(80, 8 * len(hits))
843
+ reasons.append("terms:" + ",".join(hits[:8]))
844
+ if str(node.get("main_kind", "")) in preferred_kinds:
845
+ score += 10
846
+ if not score:
847
+ continue
848
+ results.append((score, node, reasons))
849
+ results.sort(key=lambda item: (item[0], str(item[1].get("main_kind", "")) in preferred_kinds, str(item[1].get("label", ""))), reverse=True)
850
+ return [
851
+ {
852
+ "score": score,
853
+ "reasons": reasons,
854
+ "id": node.get("id"),
855
+ "label": node.get("label"),
856
+ "kind": node.get("main_kind"),
857
+ "repo": node.get("repo"),
858
+ "canonical_key": node.get("canonical_key"),
859
+ "source_file": node.get("source_file"),
860
+ "source_location": node.get("source_location"),
861
+ }
862
+ for score, node, reasons in results[:top_k]
863
+ ]
864
+
865
+
866
+ def _node_values(node: dict) -> set[str]:
867
+ vals = {
868
+ _norm(node.get("id")),
869
+ _norm(node.get("label")),
870
+ _norm(node.get("canonical_key")),
871
+ _norm(node.get("local_id")),
872
+ _norm(node.get("repo")),
873
+ _norm(node.get("source_file")),
874
+ }
875
+ return {v for v in vals if v}
876
+
877
+
878
+ def _load_graph_nodes(path: Path) -> list[dict]:
879
+ data = json.loads(path.read_text(encoding="utf-8"))
880
+ return list(data.get("nodes", []))
881
+
882
+
883
+ def _matches_graph_ref(node: dict, ref: object) -> bool:
884
+ q = _norm(ref)
885
+ if not q:
886
+ return False
887
+ if q.startswith("/"):
888
+ q_values = {q, f"rest_api:{q}"}
889
+ elif q.startswith(("mtop_api:", "rest_api:", "hsf_method:", "hsf_api:", "dependency_method:", "dependency:")):
890
+ q_values = {q}
891
+ elif str(ref).startswith("mtop."):
892
+ q_values = {q, f"mtop_api:{q}"}
893
+ else:
894
+ q_values = {q}
895
+ vals = _node_values(node)
896
+ return any(qv == v or qv in v for qv in q_values for v in vals)
897
+
898
+
899
+ def validate_business_map(map_path: Path, graph_path: Path) -> BusinessValidationResult:
900
+ data = load_business_map(map_path)
901
+ nodes = _load_graph_nodes(graph_path)
902
+ missing_anchors: list[dict] = []
903
+ missing_trace_refs: list[dict] = []
904
+ concept_count = 0
905
+ scenario_count = 0
906
+ anchor_count = 0
907
+ trace_hint_count = 0
908
+
909
+ def exists(ref: object) -> bool:
910
+ return any(_matches_graph_ref(node, ref) for node in nodes)
911
+
912
+ for concept in data.get("concepts", []):
913
+ if not isinstance(concept, dict):
914
+ continue
915
+ concept_count += 1
916
+ for scenario in concept.get("scenarios", []):
917
+ if not isinstance(scenario, dict):
918
+ continue
919
+ scenario_count += 1
920
+ scenario_id = str(scenario.get("id", ""))
921
+ for anchor, flow_id in _scenario_anchors(scenario):
922
+ anchor_count += 1
923
+ ref = anchor.get("ref", "")
924
+ if not exists(ref):
925
+ missing_anchors.append(
926
+ {
927
+ "concept": concept.get("id", ""),
928
+ "scenario": scenario_id,
929
+ "flow": flow_id,
930
+ "kind": anchor.get("kind", ""),
931
+ "ref": ref,
932
+ "role": anchor.get("role", ""),
933
+ }
934
+ )
935
+ for hint, flow_id in _scenario_trace_hints(scenario):
936
+ trace_hint_count += 1
937
+ for key in ("from", "api"):
938
+ ref = hint.get(key, "")
939
+ if ref and not exists(ref):
940
+ missing_trace_refs.append(
941
+ {
942
+ "concept": concept.get("id", ""),
943
+ "scenario": scenario_id,
944
+ "flow": flow_id,
945
+ "field": key,
946
+ "ref": ref,
947
+ }
948
+ )
949
+
950
+ return BusinessValidationResult(
951
+ concept_count=concept_count,
952
+ scenario_count=scenario_count,
953
+ anchor_count=anchor_count,
954
+ trace_hint_count=trace_hint_count,
955
+ missing_anchors=missing_anchors,
956
+ missing_trace_refs=missing_trace_refs,
957
+ )
958
+
959
+
960
+ def lint_business_map(map_path: Path) -> BusinessLintResult:
961
+ data = load_business_map(map_path)
962
+ issues: list[BusinessLintIssue] = []
963
+
964
+ def add(level: str, path: str, message: str) -> None:
965
+ issues.append(BusinessLintIssue(level=level, path=path, message=message))
966
+
967
+ def check_anchors(owner: dict, path: str) -> None:
968
+ seen: set[tuple[str, str]] = set()
969
+ for idx, anchor in enumerate(owner.get("anchors", [])):
970
+ anchor_path = f"{path}.anchors[{idx}]"
971
+ if not isinstance(anchor, dict):
972
+ add("ERROR", anchor_path, "anchor must be an object")
973
+ continue
974
+ for key in ("kind", "ref", "role", "confidence"):
975
+ if not str(anchor.get(key, "")).strip():
976
+ add("ERROR", anchor_path, f"anchor missing required field: {key}")
977
+ confidence = str(anchor.get("confidence", "")).strip()
978
+ if confidence and confidence not in {"verified", "candidate", "inferred", "stale"}:
979
+ add("ERROR", anchor_path, f"anchor confidence is not allowed: {confidence}")
980
+ identity = (str(anchor.get("kind", "")).strip(), str(anchor.get("ref", "")).strip())
981
+ if identity in seen:
982
+ add("WARN", anchor_path, f"duplicate anchor: {identity[0]} {identity[1]}")
983
+ seen.add(identity)
984
+
985
+ def check_trace_hints(owner: dict, path: str) -> None:
986
+ for idx, hint in enumerate(owner.get("trace_hints", [])):
987
+ hint_path = f"{path}.trace_hints[{idx}]"
988
+ if not isinstance(hint, dict):
989
+ add("ERROR", hint_path, "trace_hint must be an object")
990
+ continue
991
+ if not str(hint.get("from", "")).strip():
992
+ add("ERROR", hint_path, "trace_hint missing required field: from")
993
+ depth = hint.get("max_depth", 8)
994
+ if not isinstance(depth, int) or depth <= 0:
995
+ add("ERROR", hint_path, "trace_hint max_depth must be a positive integer")
996
+
997
+ def check_search_constraints(owner: dict, path: str) -> None:
998
+ constraints = owner.get("search_constraints")
999
+ if constraints is None:
1000
+ return
1001
+ if not isinstance(constraints, dict):
1002
+ add("ERROR", f"{path}.search_constraints", "search_constraints must be an object")
1003
+ return
1004
+ for key in ("prefer_repos", "exclude_repos"):
1005
+ if key in constraints and not isinstance(constraints.get(key), list):
1006
+ add("ERROR", f"{path}.search_constraints.{key}", f"{key} must be a list")
1007
+ for idx, value in enumerate(constraints.get(key, []) if isinstance(constraints.get(key), list) else []):
1008
+ if not str(value).strip():
1009
+ add("ERROR", f"{path}.search_constraints.{key}[{idx}]", f"{key} entries must be non-empty strings")
1010
+
1011
+ def check_accepted_gaps(owner: dict, path: str) -> None:
1012
+ accepted = owner.get("accepted_gaps", [])
1013
+ if accepted and not isinstance(accepted, list):
1014
+ add("ERROR", f"{path}.accepted_gaps", "accepted_gaps must be a list")
1015
+ return
1016
+ active_gap_text = "\n".join(str(g) for g in owner.get("gaps", []) if g)
1017
+ for idx, gap in enumerate(accepted):
1018
+ gap_path = f"{path}.accepted_gaps[{idx}]"
1019
+ if not isinstance(gap, dict):
1020
+ add("ERROR", gap_path, "accepted_gap must be an object")
1021
+ continue
1022
+ for key in ("id", "type", "status", "summary", "reason", "stop_rule"):
1023
+ if not str(gap.get(key, "")).strip():
1024
+ add("ERROR", gap_path, f"accepted_gap missing required field: {key}")
1025
+ if str(gap.get("type", "")).strip() == "accepted_boundary" and not str(gap.get("stop_rule", "")).strip():
1026
+ add("ERROR", gap_path, "accepted_boundary must define stop_rule")
1027
+ summary = _accepted_gap_summary(gap).strip()
1028
+ if summary and summary in active_gap_text:
1029
+ add("ERROR", gap_path, "accepted gap is duplicated in active gaps; keep it only in accepted_gaps")
1030
+
1031
+ def check_owner(owner: dict, path: str, *, require_scope_for_verified: bool) -> None:
1032
+ status = str(owner.get("status", "")).strip()
1033
+ if status and status not in _STATUS_ORDER:
1034
+ add("ERROR", path, f"status is not allowed: {status}")
1035
+ if status == "verified" and require_scope_for_verified and not str(owner.get("verification_scope", "")).strip():
1036
+ add("WARN", path, "verified flow should declare verification_scope")
1037
+ if status == "verified":
1038
+ candidate_refs = [
1039
+ str(anchor.get("ref", ""))
1040
+ for anchor in owner.get("anchors", [])
1041
+ if isinstance(anchor, dict) and str(anchor.get("confidence", "")).strip() == "candidate"
1042
+ ]
1043
+ if candidate_refs:
1044
+ add("ERROR", path, "verified item has candidate anchors: " + ", ".join(candidate_refs))
1045
+ if owner.get("accepted_gaps") and not str(owner.get("verification_scope", "")).strip():
1046
+ add("ERROR", path, "accepted_gaps require verification_scope to define the boundary")
1047
+ gaps = owner.get("gaps", [])
1048
+ if gaps is not None and not isinstance(gaps, list):
1049
+ add("ERROR", f"{path}.gaps", "gaps must be a list")
1050
+ if status == "verified" and any(g for g in gaps if g):
1051
+ add("WARN", path, "verified item has active gaps; use accepted_gaps for known boundaries")
1052
+ check_anchors(owner, path)
1053
+ check_trace_hints(owner, path)
1054
+ check_search_constraints(owner, path)
1055
+ check_accepted_gaps(owner, path)
1056
+
1057
+ if data.get("kind") != "business-map":
1058
+ add("ERROR", "<root>", "kind must be business-map")
1059
+ if "concepts" not in data or not isinstance(data.get("concepts"), list):
1060
+ add("ERROR", "<root>.concepts", "concepts must be a list")
1061
+ return BusinessLintResult(issues)
1062
+
1063
+ for concept_idx, concept in enumerate(data.get("concepts", [])):
1064
+ concept_path = f"concepts[{concept_idx}]"
1065
+ if not isinstance(concept, dict):
1066
+ add("ERROR", concept_path, "concept must be an object")
1067
+ continue
1068
+ if not str(concept.get("id", "")).strip():
1069
+ add("ERROR", concept_path, "concept missing required field: id")
1070
+ if not str(concept.get("name", "")).strip():
1071
+ add("ERROR", concept_path, "concept missing required field: name")
1072
+ scenarios = concept.get("scenarios", [])
1073
+ if not isinstance(scenarios, list):
1074
+ add("ERROR", f"{concept_path}.scenarios", "scenarios must be a list")
1075
+ continue
1076
+ flow_names: dict[str, str] = {}
1077
+ scenario_names: dict[str, str] = {}
1078
+ for scenario_idx, scenario in enumerate(scenarios):
1079
+ scenario_path = f"{concept_path}.scenarios[{scenario_idx}]"
1080
+ if not isinstance(scenario, dict):
1081
+ add("ERROR", scenario_path, "scenario must be an object")
1082
+ continue
1083
+ scenario_id = str(scenario.get("id", "")).strip()
1084
+ scenario_name = str(scenario.get("name", "")).strip()
1085
+ if not scenario_id:
1086
+ add("ERROR", scenario_path, "scenario missing required field: id")
1087
+ if not scenario_name:
1088
+ add("ERROR", scenario_path, "scenario missing required field: name")
1089
+ if scenario_name:
1090
+ scenario_names[scenario_name] = _format_business_path(concept, scenario)
1091
+ check_owner(scenario, scenario_path, require_scope_for_verified=False)
1092
+ flows = scenario.get("flows", [])
1093
+ if flows and not isinstance(flows, list):
1094
+ add("ERROR", f"{scenario_path}.flows", "flows must be a list")
1095
+ continue
1096
+ for flow_idx, flow in enumerate(flows):
1097
+ flow_path = f"{scenario_path}.flows[{flow_idx}]"
1098
+ if not isinstance(flow, dict):
1099
+ add("ERROR", flow_path, "flow must be an object")
1100
+ continue
1101
+ flow_id = str(flow.get("id", "")).strip()
1102
+ flow_name = str(flow.get("name", "")).strip()
1103
+ if not flow_id:
1104
+ add("ERROR", flow_path, "flow missing required field: id")
1105
+ if not flow_name:
1106
+ add("ERROR", flow_path, "flow missing required field: name")
1107
+ if flow_name:
1108
+ flow_names[flow_name] = _format_business_path(concept, scenario, flow)
1109
+ check_owner(flow, flow_path, require_scope_for_verified=True)
1110
+ for scenario_name, scenario_path in scenario_names.items():
1111
+ flow_path = flow_names.get(scenario_name)
1112
+ if flow_path:
1113
+ add(
1114
+ "WARN",
1115
+ scenario_path,
1116
+ f"scenario name overlaps a flow ({flow_path}); document whether the scenario is a broader backlog or remove the duplicate",
1117
+ )
1118
+
1119
+ return BusinessLintResult(issues)
1120
+
1121
+
1122
+ def format_business_show(data: dict, *, concept_query: str = "", scenario_query: str = "") -> str:
1123
+ concepts = [find_concept(data, concept_query)] if concept_query else [
1124
+ c for c in data.get("concepts", []) if isinstance(c, dict)
1125
+ ]
1126
+ lines: list[str] = []
1127
+ for concept in concepts:
1128
+ lines.append(f"Concept: {concept.get('name', concept.get('id', ''))} ({concept.get('id', '')})")
1129
+ lines.append(f"Status: {concept.get('status', '')}")
1130
+ aliases = concept.get("aliases", [])
1131
+ if aliases:
1132
+ lines.append(f"Aliases: {', '.join(str(a) for a in aliases)}")
1133
+ if concept.get("summary"):
1134
+ lines.append(f"Summary: {concept.get('summary')}")
1135
+ lines.append("")
1136
+ lines.append("Scenarios:")
1137
+ for scenario in find_scenarios(concept, scenario_query):
1138
+ lines.append(f"- {scenario.get('name', scenario.get('id', ''))} ({scenario.get('id', '')}) status={scenario.get('status', '')}")
1139
+ if scenario.get("summary"):
1140
+ lines.append(f" summary: {scenario.get('summary')}")
1141
+ if scenario.get("verification_scope"):
1142
+ lines.append(f" verification_scope: {scenario.get('verification_scope')}")
1143
+ constraints = _merge_search_constraints(scenario)
1144
+ if constraints["prefer_repos"] or constraints["exclude_repos"]:
1145
+ lines.append(
1146
+ " search_constraints: "
1147
+ f"prefer_repos={', '.join(constraints['prefer_repos']) or '-'} "
1148
+ f"exclude_repos={', '.join(constraints['exclude_repos']) or '-'}"
1149
+ )
1150
+ anchors = [a for a, flow_id in _scenario_anchors(scenario) if not flow_id]
1151
+ if anchors:
1152
+ lines.append(" anchors:")
1153
+ for anchor in anchors:
1154
+ role = f" role={anchor.get('role', '')}" if anchor.get("role") else ""
1155
+ confidence = f" confidence={anchor.get('confidence', '')}" if anchor.get("confidence") else ""
1156
+ lines.append(f" - {anchor.get('kind', '')}: {anchor.get('ref', '')}{role}{confidence}")
1157
+ hints = [h for h, flow_id in _scenario_trace_hints(scenario) if not flow_id]
1158
+ if hints:
1159
+ lines.append(" trace_hints:")
1160
+ for hint in hints:
1161
+ api = f" --api {hint.get('api')}" if hint.get("api") else ""
1162
+ lines.append(f" - --from {hint.get('from', '')}{api} --max-depth {hint.get('max_depth', 8)}")
1163
+ flows = _scenario_flows(scenario)
1164
+ if flows:
1165
+ lines.append(" flows:")
1166
+ for flow in flows:
1167
+ lines.append(
1168
+ f" - {flow.get('name', flow.get('id', ''))} ({flow.get('id', '')}) "
1169
+ f"status={flow.get('status', scenario.get('status', ''))}"
1170
+ )
1171
+ if flow.get("summary"):
1172
+ lines.append(f" summary: {flow.get('summary')}")
1173
+ if flow.get("verification_scope"):
1174
+ lines.append(f" verification_scope: {flow.get('verification_scope')}")
1175
+ flow_constraints = _merge_search_constraints(scenario, flow)
1176
+ if flow_constraints["prefer_repos"] or flow_constraints["exclude_repos"]:
1177
+ lines.append(
1178
+ " search_constraints: "
1179
+ f"prefer_repos={', '.join(flow_constraints['prefer_repos']) or '-'} "
1180
+ f"exclude_repos={', '.join(flow_constraints['exclude_repos']) or '-'}"
1181
+ )
1182
+ flow_anchors = [a for a in flow.get("anchors", []) if isinstance(a, dict)]
1183
+ if flow_anchors:
1184
+ lines.append(" anchors:")
1185
+ for anchor in flow_anchors:
1186
+ role = f" role={anchor.get('role', '')}" if anchor.get("role") else ""
1187
+ confidence = f" confidence={anchor.get('confidence', '')}" if anchor.get("confidence") else ""
1188
+ lines.append(f" - {anchor.get('kind', '')}: {anchor.get('ref', '')}{role}{confidence}")
1189
+ flow_hints = [h for h in flow.get("trace_hints", []) if isinstance(h, dict)]
1190
+ if flow_hints:
1191
+ lines.append(" trace_hints:")
1192
+ for hint in flow_hints:
1193
+ api = f" --api {hint.get('api')}" if hint.get("api") else ""
1194
+ lines.append(f" - --from {hint.get('from', '')}{api} --max-depth {hint.get('max_depth', 8)}")
1195
+ flow_gaps = [g for g in flow.get("gaps", []) if g]
1196
+ if flow_gaps:
1197
+ lines.append(" gaps:")
1198
+ for gap in flow_gaps:
1199
+ lines.append(f" - {gap}")
1200
+ accepted_gaps = [g for g in flow.get("accepted_gaps", []) if g]
1201
+ if accepted_gaps:
1202
+ lines.append(" accepted_gaps:")
1203
+ for gap in accepted_gaps:
1204
+ lines.append(f" - {_format_accepted_gap(gap)}")
1205
+ gaps = [g for g in scenario.get("gaps", []) if g]
1206
+ if gaps:
1207
+ lines.append(" gaps:")
1208
+ for gap in gaps:
1209
+ lines.append(f" - {gap}")
1210
+ accepted_gaps = [g for g in scenario.get("accepted_gaps", []) if g]
1211
+ if accepted_gaps:
1212
+ lines.append(" accepted_gaps:")
1213
+ for gap in accepted_gaps:
1214
+ lines.append(f" - {_format_accepted_gap(gap)}")
1215
+ lines.append("")
1216
+ return "\n".join(lines).rstrip()
1217
+
1218
+
1219
+ def format_validation_result(result: BusinessValidationResult) -> str:
1220
+ lines = [
1221
+ "Business map validation:",
1222
+ f"- concepts: {result.concept_count}",
1223
+ f"- scenarios: {result.scenario_count}",
1224
+ f"- anchors: {result.anchor_count}",
1225
+ f"- trace_hints: {result.trace_hint_count}",
1226
+ f"- status: {'OK' if result.ok else 'STALE'}",
1227
+ ]
1228
+ if result.missing_anchors:
1229
+ lines.append("")
1230
+ lines.append("Missing anchors:")
1231
+ for item in result.missing_anchors:
1232
+ flow = f" / {item.get('flow')}" if item.get("flow") else ""
1233
+ lines.append(
1234
+ f"- {item.get('concept')} / {item.get('scenario')}{flow} {item.get('kind')} "
1235
+ f"{item.get('ref')} role={item.get('role', '')}"
1236
+ )
1237
+ if result.missing_trace_refs:
1238
+ lines.append("")
1239
+ lines.append("Missing trace refs:")
1240
+ for item in result.missing_trace_refs:
1241
+ flow = f" / {item.get('flow')}" if item.get("flow") else ""
1242
+ lines.append(f"- {item.get('concept')} / {item.get('scenario')}{flow} {item.get('field')}={item.get('ref')}")
1243
+ return "\n".join(lines)
1244
+
1245
+
1246
+ def format_lint_result(result: BusinessLintResult) -> str:
1247
+ lines = [
1248
+ "Business map lint:",
1249
+ f"- errors: {len(result.errors)}",
1250
+ f"- warnings: {len(result.warnings)}",
1251
+ f"- status: {'OK' if result.ok else 'FAILED'}",
1252
+ ]
1253
+ if result.issues:
1254
+ lines.append("")
1255
+ lines.append("Issues:")
1256
+ for issue in result.issues:
1257
+ lines.append(f"- [{issue.level}] {issue.path}: {issue.message}")
1258
+ return "\n".join(lines)
1259
+
1260
+
1261
+ def trace_business(
1262
+ *,
1263
+ map_path: Path,
1264
+ graph_path: Path,
1265
+ concept_query: str,
1266
+ scenario_query: str = "",
1267
+ flow_query: str = "",
1268
+ max_depth: int | None = None,
1269
+ include_sources: bool = False,
1270
+ ) -> str:
1271
+ data = load_business_map(map_path)
1272
+ concept = find_concept(data, concept_query)
1273
+ scenarios = find_scenarios(concept, scenario_query)
1274
+ runnable = [
1275
+ scenario
1276
+ for scenario in scenarios
1277
+ if str(scenario.get("status", "")) != "unexplored" and _scenario_trace_items(scenario, flow_query)
1278
+ ]
1279
+ if scenario_query and not runnable:
1280
+ scenario = scenarios[0]
1281
+ flow = find_flow(scenario, flow_query) if flow_query else None
1282
+ owner = flow or scenario
1283
+ gaps = _compact_gap_items([g for g in owner.get("gaps", []) if g])
1284
+ accepted_gaps = _compact_gap_items([g for g in owner.get("accepted_gaps", []) if g])
1285
+ boundary_lines = []
1286
+ if gaps:
1287
+ boundary_lines.append("Gaps:")
1288
+ boundary_lines.extend(f"- {gap}" for gap in gaps)
1289
+ if accepted_gaps:
1290
+ boundary_lines.append("Accepted gaps:")
1291
+ boundary_lines.extend(f"- {gap}" for gap in accepted_gaps)
1292
+ return "\n".join(
1293
+ [
1294
+ f"Concept: {concept.get('name', concept.get('id', ''))}",
1295
+ f"Scenario: {scenario.get('name', scenario.get('id', ''))} status={scenario.get('status', '')}",
1296
+ f"Flow: {flow.get('name', flow.get('id', ''))} status={flow.get('status', scenario.get('status', ''))}" if flow else "",
1297
+ owner.get("summary", ""),
1298
+ *boundary_lines,
1299
+ "No trace_hints are available yet.",
1300
+ ]
1301
+ ).strip()
1302
+
1303
+ lines = [
1304
+ f"Business concept: {concept.get('name', concept.get('id', ''))} ({concept.get('id', '')})",
1305
+ f"Graph: {graph_path}",
1306
+ f"Map: {map_path}",
1307
+ ]
1308
+ for scenario in runnable:
1309
+ lines.extend(
1310
+ [
1311
+ "",
1312
+ f"Scenario: {scenario.get('name', scenario.get('id', ''))} ({scenario.get('id', '')}) status={scenario.get('status', '')}",
1313
+ ]
1314
+ )
1315
+ if scenario.get("summary"):
1316
+ lines.append(f"Summary: {scenario.get('summary')}")
1317
+ if flow_query:
1318
+ flow = find_flow(scenario, flow_query)
1319
+ if flow is not None:
1320
+ lines.append(f"Flow: {flow.get('name', flow.get('id', ''))} ({flow.get('id', '')}) status={flow.get('status', scenario.get('status', ''))}")
1321
+ if flow.get("summary"):
1322
+ lines.append(f"Flow summary: {flow.get('summary')}")
1323
+ if flow.get("verification_scope"):
1324
+ lines.append(f"Flow verification scope: {flow.get('verification_scope')}")
1325
+ gaps = _compact_gap_items([g for g in flow.get("gaps", []) if g])
1326
+ accepted_gaps = _compact_gap_items([g for g in flow.get("accepted_gaps", []) if g])
1327
+ if gaps:
1328
+ lines.append("Flow gaps:")
1329
+ lines.extend(f"- {gap}" for gap in gaps)
1330
+ if accepted_gaps:
1331
+ lines.append("Flow accepted gaps:")
1332
+ lines.extend(f"- {gap}" for gap in accepted_gaps)
1333
+ elif scenario.get("accepted_gaps") or scenario.get("gaps"):
1334
+ gaps = _compact_gap_items([g for g in scenario.get("gaps", []) if g])
1335
+ accepted_gaps = _compact_gap_items([g for g in scenario.get("accepted_gaps", []) if g])
1336
+ if gaps:
1337
+ lines.append("Scenario gaps:")
1338
+ lines.extend(f"- {gap}" for gap in gaps)
1339
+ if accepted_gaps:
1340
+ lines.append("Scenario accepted gaps:")
1341
+ lines.extend(f"- {gap}" for gap in accepted_gaps)
1342
+ for idx, (hint, flow_id, constraints) in enumerate(_scenario_trace_items(scenario, flow_query), start=1):
1343
+ depth = max_depth if max_depth is not None else int(hint.get("max_depth", 8) or 8)
1344
+ flow_label = f" [{flow_id}]" if flow_id else ""
1345
+ lines.extend(["", f"Trace hint {idx}{flow_label}: --from {hint.get('from', '')}" + (f" --api {hint.get('api')}" if hint.get("api") else "")])
1346
+ if constraints["prefer_repos"] or constraints["exclude_repos"]:
1347
+ lines.append(
1348
+ "Search constraints: "
1349
+ f"prefer_repos={', '.join(constraints['prefer_repos']) or '-'} "
1350
+ f"exclude_repos={', '.join(constraints['exclude_repos']) or '-'}"
1351
+ )
1352
+ try:
1353
+ lines.append(
1354
+ trace_graph_main(
1355
+ graph_path,
1356
+ from_query=str(hint.get("from", "")),
1357
+ api_query=str(hint.get("api", "")),
1358
+ max_depth=depth,
1359
+ include_sources=include_sources,
1360
+ prefer_repos=constraints["prefer_repos"],
1361
+ exclude_repos=constraints["exclude_repos"],
1362
+ )
1363
+ )
1364
+ except Exception as exc:
1365
+ lines.append(f"[trace failed] {exc}")
1366
+
1367
+ unexplored = [s for s in scenarios if str(s.get("status", "")) == "unexplored"]
1368
+ if unexplored:
1369
+ lines.append("")
1370
+ lines.append("Unexplored scenarios:")
1371
+ for scenario in unexplored:
1372
+ lines.append(f"- {scenario.get('name', scenario.get('id', ''))}: {scenario.get('summary', '')}")
1373
+ return "\n".join(lines)
1374
+
1375
+
1376
+ def query_business(
1377
+ *,
1378
+ map_path: Path,
1379
+ graph_path: Path | None,
1380
+ query: str,
1381
+ top_k: int = 5,
1382
+ max_depth: int | None = None,
1383
+ include_sources: bool = False,
1384
+ run_trace: bool = False,
1385
+ ) -> dict:
1386
+ data = load_business_map(map_path)
1387
+ match = match_business_query(data, query, top_k=top_k)
1388
+ result: dict = {
1389
+ "query": query,
1390
+ "map": str(map_path),
1391
+ "graph": str(graph_path) if graph_path else None,
1392
+ "match": match,
1393
+ "business_context": _business_context(data, query),
1394
+ "trace": None,
1395
+ "graph_matches": [],
1396
+ "next_action": "",
1397
+ }
1398
+ best = match.get("best")
1399
+ if match.get("status") == "matched" and best and run_trace and graph_path is not None:
1400
+ if best.get("scenario"):
1401
+ result["trace"] = trace_business(
1402
+ map_path=map_path,
1403
+ graph_path=graph_path,
1404
+ concept_query=str(best.get("concept") or ""),
1405
+ scenario_query=str(best.get("scenario") or ""),
1406
+ flow_query=str(best.get("flow") or ""),
1407
+ max_depth=max_depth,
1408
+ include_sources=include_sources,
1409
+ )
1410
+ result["next_action"] = "answer_from_trace_and_read_source_if_field_level_detail_is_needed"
1411
+ else:
1412
+ result["next_action"] = "matched_concept_list_or_trace_relevant_scenarios"
1413
+ elif match.get("status") == "matched":
1414
+ result["next_action"] = "matched_business_scope_trace_or_read_source_if_needed"
1415
+ else:
1416
+ result["next_action"] = "no_business_match_use_business_context_for_boundaries_then_search_graph_and_read_source"
1417
+
1418
+ if graph_path is not None and match.get("status") == "none":
1419
+ result["graph_matches"] = search_graph_main_matches(graph_path, query, top_k=10)
1420
+ return result