Skip to content

Commit d18432b

Browse files
committed
feat(profile-pack): add field-level conversion diagnostics evidence
1 parent 5d2bbd3 commit d18432b

6 files changed

Lines changed: 284 additions & 13 deletions

File tree

docs/en/reference/development-active-workstreams.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Completed:
3232
1. Compatibility diagnostics now expose deterministic issue-group buckets (`integrity`, `security`, `version`, `conversion`, `environment`, `unknown`) for import payloads and review evidence.
3333
2. Compatibility diagnostics now expose per-issue details (`sections`, `related_paths`, `evidence_refs`) to support field-level triage without manual payload archaeology.
3434
3. Member compatibility guidance now consumes `compatibility_issue_details` and keeps issue-level section/path/evidence metadata available to UI actions.
35+
4. Raw AstrBot conversion now emits field-level diagnostics (`summary.field_diagnostics`) and conversion issue evidence can be resolved from those diagnostics when scan-level evidence is absent.
3536

3637
Remaining:
3738

docs/ja/reference/development-active-workstreams.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
1. import 診断に決定的な issue-group bucket(`integrity` / `security` / `version` / `conversion` / `environment` / `unknown`)を追加し、import payload と review evidence の双方で一貫して参照できるようにした。
3333
2. import 診断に issue 単位の詳細(`sections` / `related_paths` / `evidence_refs`)を追加し、payload を手作業で掘らなくても path レベルで分診できるようにした。
3434
3. member 互換性ガイダンスが `compatibility_issue_details` を取り込むようになり、issue 単位の section/path/evidence メタデータを UI アクション側へ保持できるようにした。
35+
4. raw AstrBot 変換はフィールド単位診断(`summary.field_diagnostics`)を出力するようになり、scan-level evidence が無い場合でも conversion issue の `evidence_refs` を診断情報から解決できるようにした。
3536

3637
継続:
3738

docs/zh/reference/development-active-workstreams.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
1. 导入诊断已输出确定性的问题分组桶(`integrity``security``version``conversion``environment``unknown`),并在导入载荷与审阅证据中保持一致。
3333
2. 导入诊断已输出字段级 issue 详情(`sections``related_paths``evidence_refs`),支持无需手工翻 payload 的路径级分诊。
3434
3. 用户侧兼容性指导已接入 `compatibility_issue_details`,前端动作可保留 issue 级 section/path/evidence 元数据。
35+
4. 原始 AstrBot 转换已输出字段级转换诊断(`summary.field_diagnostics`),当扫描证据缺失时可用这些诊断回填 conversion issue 的 `evidence_refs`
3536

3637
待推进:
3738

sharelife/application/services_profile_pack.py

Lines changed: 228 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,25 @@ def _build_astrbot_conversion_sections(
701701
) -> tuple[dict[str, Any], list[str]]:
702702
issues: list[str] = []
703703
sections: dict[str, Any] = {}
704+
field_diagnostics: list[dict[str, Any]] = [
705+
self._astrbot_field_diagnostic(
706+
source_path=f"astrbot:{source_type}",
707+
target_path="sharelife_meta.astrbot_import.source_type",
708+
outcome="mapped",
709+
issue_code="astrbot_raw_import_converted",
710+
note="raw astrbot payload converted into standard profile-pack sections",
711+
)
712+
]
713+
if source_type == "astrbot_backup_zip":
714+
field_diagnostics.append(
715+
self._astrbot_field_diagnostic(
716+
source_path="manifest.json",
717+
target_path="sharelife_meta.astrbot_import.backup_manifest",
718+
outcome="omitted_runtime_payload",
719+
issue_code="astrbot_backup_runtime_payload_omitted",
720+
note="backup runtime payload omitted; backup manifest retained for traceability",
721+
)
722+
)
704723
personas_payload, personas_summary = self._build_astrbot_conversion_personas(raw_config)
705724
if personas_payload:
706725
sections["personas"] = personas_payload
@@ -711,8 +730,19 @@ def _build_astrbot_conversion_sections(
711730

712731
core_payload: dict[str, Any] = {}
713732
operator_keys = {"dashboard", "admins_id"}
714-
if any(key in raw_config for key in operator_keys):
733+
operator_omitted = [key for key in sorted(operator_keys) if key in raw_config]
734+
if operator_omitted:
715735
issues.append("astrbot_operator_fields_omitted")
736+
for key in operator_omitted:
737+
field_diagnostics.append(
738+
self._astrbot_field_diagnostic(
739+
source_path=key,
740+
target_path=f"astrbot_core.{key}",
741+
outcome="omitted",
742+
issue_code="astrbot_operator_fields_omitted",
743+
note="operator-managed field omitted from member-importable profile-pack payload",
744+
)
745+
)
716746
extracted_keys = {
717747
"provider",
718748
"plugin_set",
@@ -734,12 +764,14 @@ def _build_astrbot_conversion_sections(
734764
if core_payload:
735765
sections["astrbot_core"] = core_payload
736766

737-
providers_payload = self._build_astrbot_conversion_providers(raw_config)
767+
providers_payload, provider_diagnostics = self._build_astrbot_conversion_providers(raw_config)
768+
field_diagnostics.extend(provider_diagnostics)
738769
if providers_payload:
739770
sections["providers"] = providers_payload
740771

741-
plugins_payload, plugin_issues = self._build_astrbot_conversion_plugins(raw_config)
772+
plugins_payload, plugin_issues, plugin_diagnostics = self._build_astrbot_conversion_plugins(raw_config)
742773
issues.extend(plugin_issues)
774+
field_diagnostics.extend(plugin_diagnostics)
743775
if plugins_payload is not None:
744776
sections["plugins"] = plugins_payload
745777

@@ -748,6 +780,7 @@ def _build_astrbot_conversion_sections(
748780
personas_summary=personas_summary,
749781
environment_summary=environment_summary,
750782
plugins_payload=plugins_payload,
783+
field_diagnostics=field_diagnostics,
751784
)
752785

753786
sections["sharelife_meta"] = {
@@ -763,35 +796,83 @@ def _build_astrbot_conversion_sections(
763796
}
764797
return sections, self._dedupe_issue_codes(issues)
765798

766-
def _build_astrbot_conversion_providers(self, raw_config: dict[str, Any]) -> dict[str, Any]:
799+
def _build_astrbot_conversion_providers(self, raw_config: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
767800
providers = raw_config.get("provider", [])
768801
if not isinstance(providers, list):
769-
return {}
802+
return {}, []
770803
out: dict[str, Any] = {}
804+
diagnostics: list[dict[str, Any]] = []
771805
for index, item in enumerate(providers):
772806
if not isinstance(item, dict):
773807
continue
774808
provider_id = str(item.get("id", "") or "").strip() or f"provider-{index + 1}"
809+
source_provider_path = f"provider[{index}]"
775810
normalized = deepcopy(item)
776811
if "key" in normalized and "api_key" not in normalized:
777812
normalized["api_key"] = normalized.pop("key")
813+
diagnostics.append(
814+
self._astrbot_field_diagnostic(
815+
source_path=f"{source_provider_path}.key",
816+
target_path=f"providers.{provider_id}.api_key",
817+
outcome="mapped_redacted",
818+
issue_code="astrbot_raw_import_converted",
819+
note="provider key renamed to api_key and redacted",
820+
)
821+
)
822+
elif "api_key" in normalized:
823+
diagnostics.append(
824+
self._astrbot_field_diagnostic(
825+
source_path=f"{source_provider_path}.api_key",
826+
target_path=f"providers.{provider_id}.api_key",
827+
outcome="mapped_redacted",
828+
issue_code="astrbot_raw_import_converted",
829+
note="provider api_key retained and redacted",
830+
)
831+
)
778832
normalized = self._sanitize_astrbot_conversion_value(
779833
normalized,
780834
path=f"providers.{provider_id}",
781835
)
782836
out[provider_id] = normalized
783-
return out
837+
return out, self._dedupe_astrbot_field_diagnostics(diagnostics)
784838

785-
def _build_astrbot_conversion_plugins(self, raw_config: dict[str, Any]) -> tuple[dict[str, Any] | None, list[str]]:
839+
def _build_astrbot_conversion_plugins(
840+
self,
841+
raw_config: dict[str, Any],
842+
) -> tuple[dict[str, Any] | None, list[str], list[dict[str, Any]]]:
786843
plugin_set = raw_config.get("plugin_set")
787844
if plugin_set is None:
788-
return None, []
845+
return None, [], []
789846
if not isinstance(plugin_set, list):
790-
return {}, []
847+
return {}, [], []
791848
normalized = [str(item or "").strip() for item in plugin_set if str(item or "").strip()]
792849
if "*" in normalized:
793-
return None, ["astrbot_plugin_wildcard_unresolved"]
794-
return {plugin_id: {"enabled": True} for plugin_id in normalized}, []
850+
return (
851+
None,
852+
["astrbot_plugin_wildcard_unresolved"],
853+
[
854+
self._astrbot_field_diagnostic(
855+
source_path="plugin_set",
856+
target_path="plugins",
857+
outcome="requires_manual_resolution",
858+
issue_code="astrbot_plugin_wildcard_unresolved",
859+
note="plugin wildcard cannot be resolved into deterministic plugin ids",
860+
)
861+
],
862+
)
863+
return (
864+
{plugin_id: {"enabled": True} for plugin_id in normalized},
865+
[],
866+
[
867+
self._astrbot_field_diagnostic(
868+
source_path="plugin_set",
869+
target_path="plugins",
870+
outcome="mapped",
871+
issue_code="astrbot_raw_import_converted",
872+
note="plugin_set converted into plugins section",
873+
)
874+
] if normalized else [],
875+
)
795876

796877
def _build_astrbot_conversion_personas(self, raw_config: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any]]:
797878
payload: dict[str, Any] = {}
@@ -895,6 +976,7 @@ def _build_astrbot_conversion_summary(
895976
personas_summary: dict[str, Any],
896977
environment_summary: dict[str, Any],
897978
plugins_payload: dict[str, Any] | None,
979+
field_diagnostics: list[dict[str, Any]] | None = None,
898980
) -> dict[str, Any]:
899981
summary: dict[str, Any] = {}
900982
summary.update(personas_summary)
@@ -904,8 +986,63 @@ def _build_astrbot_conversion_summary(
904986
provider_count = raw_config.get("provider")
905987
if isinstance(provider_count, list):
906988
summary["provider_count"] = len(provider_count)
989+
normalized_diagnostics = self._dedupe_astrbot_field_diagnostics(field_diagnostics or [])
990+
if normalized_diagnostics:
991+
summary["field_diagnostics"] = normalized_diagnostics
992+
summary["field_diagnostic_count"] = len(normalized_diagnostics)
993+
outcome_counts: dict[str, int] = {}
994+
for item in normalized_diagnostics:
995+
outcome = str(item.get("outcome", "") or "").strip() or "unknown"
996+
outcome_counts[outcome] = outcome_counts.get(outcome, 0) + 1
997+
summary["field_diagnostic_outcomes"] = outcome_counts
907998
return summary
908999

1000+
@staticmethod
1001+
def _astrbot_field_diagnostic(
1002+
*,
1003+
source_path: str,
1004+
target_path: str = "",
1005+
outcome: str,
1006+
issue_code: str = "",
1007+
note: str = "",
1008+
) -> dict[str, Any]:
1009+
return {
1010+
"source_path": str(source_path or "").strip(),
1011+
"target_path": str(target_path or "").strip(),
1012+
"outcome": str(outcome or "").strip(),
1013+
"issue_code": str(issue_code or "").strip(),
1014+
"note": str(note or "").strip(),
1015+
}
1016+
1017+
@staticmethod
1018+
def _dedupe_astrbot_field_diagnostics(values: list[dict[str, Any]]) -> list[dict[str, Any]]:
1019+
out: list[dict[str, Any]] = []
1020+
seen: set[tuple[str, str, str, str, str]] = set()
1021+
for item in values:
1022+
if not isinstance(item, dict):
1023+
continue
1024+
source_path = str(item.get("source_path", "") or "").strip()
1025+
target_path = str(item.get("target_path", "") or "").strip()
1026+
outcome = str(item.get("outcome", "") or "").strip()
1027+
issue_code = str(item.get("issue_code", "") or "").strip()
1028+
note = str(item.get("note", "") or "").strip()
1029+
if not source_path and not target_path:
1030+
continue
1031+
key = (source_path, target_path, outcome, issue_code, note)
1032+
if key in seen:
1033+
continue
1034+
seen.add(key)
1035+
out.append(
1036+
{
1037+
"source_path": source_path,
1038+
"target_path": target_path,
1039+
"outcome": outcome,
1040+
"issue_code": issue_code,
1041+
"note": note,
1042+
}
1043+
)
1044+
return out
1045+
9091046
def _sanitize_astrbot_conversion_value(self, value: Any, *, path: str) -> Any:
9101047
if isinstance(value, dict):
9111048
out: dict[str, Any] = {}
@@ -2934,6 +3071,8 @@ def compatibility_issue_details(
29343071
"sections": related_sections,
29353072
"related_paths": related_paths,
29363073
"evidence_refs": cls._compatibility_issue_evidence_refs(
3074+
issue_code=issue_code,
3075+
sections=normalized_sections,
29373076
scan_summary=scan_summary,
29383077
related_sections=related_sections,
29393078
related_paths=related_paths,
@@ -3134,13 +3273,13 @@ def visit(node: Any, path: str, depth: int) -> None:
31343273
def _compatibility_issue_evidence_refs(
31353274
cls,
31363275
*,
3276+
issue_code: str,
3277+
sections: dict[str, Any] | None,
31373278
scan_summary: dict[str, Any] | None,
31383279
related_sections: list[str],
31393280
related_paths: list[str],
31403281
) -> list[dict[str, Any]]:
31413282
rows = list((scan_summary or {}).get("risk_evidence", []) or [])
3142-
if not rows:
3143-
return []
31443283
section_files = {f"sections/{section}.json" for section in related_sections if section}
31453284
normalized_paths = [str(path or "").strip() for path in related_paths if str(path or "").strip()]
31463285
out: list[dict[str, Any]] = []
@@ -3185,6 +3324,82 @@ def _compatibility_issue_evidence_refs(
31853324
continue
31863325
seen.add(key)
31873326
out.append(record)
3327+
if out:
3328+
return out
3329+
return cls._compatibility_issue_conversion_evidence_refs(
3330+
issue_code=issue_code,
3331+
sections=sections,
3332+
related_paths=normalized_paths,
3333+
)
3334+
3335+
@classmethod
3336+
def _compatibility_issue_conversion_evidence_refs(
3337+
cls,
3338+
*,
3339+
issue_code: str,
3340+
sections: dict[str, Any] | None,
3341+
related_paths: list[str],
3342+
) -> list[dict[str, Any]]:
3343+
if cls._compatibility_issue_bucket(issue_code) != "conversion":
3344+
return []
3345+
meta = (sections or {}).get("sharelife_meta")
3346+
if not isinstance(meta, dict):
3347+
return []
3348+
astrbot_import = meta.get("astrbot_import")
3349+
if not isinstance(astrbot_import, dict):
3350+
return []
3351+
summary = astrbot_import.get("summary")
3352+
if not isinstance(summary, dict):
3353+
return []
3354+
diagnostics = summary.get("field_diagnostics")
3355+
if not isinstance(diagnostics, list):
3356+
return []
3357+
normalized_issue_code = str(issue_code or "").strip()
3358+
out: list[dict[str, Any]] = []
3359+
seen: set[tuple[str, str, str, str, str]] = set()
3360+
for item in diagnostics:
3361+
if len(out) >= 8:
3362+
break
3363+
if not isinstance(item, dict):
3364+
continue
3365+
row_issue_code = str(item.get("issue_code", "") or "").strip()
3366+
if row_issue_code and row_issue_code != normalized_issue_code:
3367+
continue
3368+
target_path = str(item.get("target_path", "") or "").strip()
3369+
source_path = str(item.get("source_path", "") or "").strip()
3370+
diagnostic_path = target_path or source_path
3371+
if not diagnostic_path:
3372+
continue
3373+
if related_paths:
3374+
matched = False
3375+
for path_ref in related_paths:
3376+
ref = str(path_ref or "").strip()
3377+
if not ref or ref.startswith("manifest."):
3378+
continue
3379+
if diagnostic_path == ref or diagnostic_path.startswith(f"{ref}.") or diagnostic_path.startswith(f"{ref}["):
3380+
matched = True
3381+
break
3382+
if not matched:
3383+
continue
3384+
outcome = str(item.get("outcome", "") or "").strip() or "conversion"
3385+
record = {
3386+
"file": "sections/sharelife_meta.json",
3387+
"path": diagnostic_path,
3388+
"line": 0,
3389+
"column": 0,
3390+
"rule": f"conversion_{outcome}",
3391+
}
3392+
key = (
3393+
record["file"],
3394+
record["path"],
3395+
str(record["line"]),
3396+
str(record["column"]),
3397+
record["rule"],
3398+
)
3399+
if key in seen:
3400+
continue
3401+
seen.add(key)
3402+
out.append(record)
31883403
return out
31893404

31903405
@staticmethod

0 commit comments

Comments
 (0)