:图谱推荐契约与可解释理由落地)
在神兽详情里继续阅读时下一张卡片需要说明“推荐了谁”和“为什么推荐”。山海万灵把这两个问题收敛为图谱推荐契约页面接收候选节点、分数和关系理由再把候选神兽渲染成可打开的图鉴卡片。这样推荐入口不会把接口字段、离线策略和展示文案混在同一个页面里。推荐结果先固定为可读契约客户端使用一个稳定模型承接不同数据来源。name用于识别候选节点graphReason是面向读者的关联说明score保留排序依据页面不需要知道候选来自 HTTP 还是本地数据。export interface GraphRecommendationItem { nodeType: string nodeId: string name: string graphReason: string score: number }这组字段有两个直接收益一是详情页只依赖统一模型二是推荐理由成为数据的一部分而不是组件临时拼出的猜测文案。候选节点缺少名称、关系理由或目标编号时应在 Repository 映射阶段拒绝进入展示列表。Repository 把接口结果转换为领域模型在线数据路径以节点类型和节点编号查询推荐结果随后把响应中的项目映射为客户端模型。调用方只调用listGraphRecommendations不拼接 URL也不处理服务端响应外壳。async listGraphRecommendations(nodeType: string, nodeId: string) { const response await apiClient.getJson( /graph/recommendations?nodeType nodeType nodeId nodeId ) return response.data.items.map(toGraphRecommendationItem) }映射函数同时完成最小字段筛选避免不完整的服务端数据进入页面。这里不把接口对象原样透传当节点编号或关系理由为空时直接忽略该项后续由空结果分支决定是否显示面板。function toGraphRecommendationItem(item: ApiGraphRecommendation) { const nodeType String(item.nodeType || ).trim() const nodeId String(item.nodeId || ).trim() const name String(item.name || ).trim() const graphReason String(item.graphReason || ).trim() const score Number(item.score) if (!nodeType || !nodeId || !name || !graphReason) { return undefined } return { nodeType: nodeType, nodeId: nodeId, name: name, graphReason: graphReason, score: Number.isFinite(score) ? score : 0 } }层次负责内容不负责内容Repository请求、映射、字段校验卡片布局与点击跳转ViewModel当前神兽上下文、候选列表状态直接访问网络ArkUI 组件标题、候选卡片、关系理由推测图谱关系这种分层让详情页在替换服务端实现时保持稳定也让 Mock 与真实接口具有相同的消费方式。HTTP 失败时仍保留可用的探索入口推荐不是详情页的唯一内容网络异常不应让读者失去继续探索的入口。Fallback Repository 先尝试主数据源请求失败后切换到本地实现并将后续读取维持在可用路径上。async listGraphRecommendations(nodeType: string, nodeId: string) { if (this.primaryReady) { try { return await this.primary.listGraphRecommendations(nodeType, nodeId) } catch (_) { this.primaryReady false } } return this.fallback.listGraphRecommendations(nodeType, nodeId) }本地候选按照区域和展厅关系生成确定性排序同展厅候选优先于普通候选理由字段与排序依据同时返回。它适合离线浏览和回归验证完整的个性化偏好、长期曝光去重和在线学习排序仍属于独立能力不由这条本地路径替代。本地规则把关系解释和排序放在同一处生成保证每个候选都有可回读的理由。候选集合先排除当前神兽再按同展厅、同区域和名称顺序确定结果最终只保留有限数量的卡片避免详情页被无关候选淹没。function compareCandidate(left: Candidate, right: Candidate): number { if (left.sameHall ! right.sameHall) { return left.sameHall ? -1 : 1 } if (left.sameRegion ! right.sameRegion) { return left.sameRegion ? -1 : 1 } return left.name.localeCompare(right.name) } function explainCandidate(item: Candidate): string { if (item.sameHall) return 同展厅关联 if (item.sameRegion) return 同区域关联 return 补充图谱覆盖 } const visibleCandidates candidates.filter(Boolean).slice(0, 4)推荐组件只渲染已经返回的关系理由推荐面板通过nodeId找回目标神兽再将graphReason作为卡片的上下文说明。点击卡片后页面使用目标编号打开对应的图鉴详情避免把推荐结果复制成另一套静态内容。ShanhaiBeastCard({ beast: recommendationBeast(item), contextText: 关联 item.graphReason, onOpen: (beastId) this.onOpen(beastId) })当前白泽详情页已显示“图谱推荐”区域候选卡片“猰貐”展示了“关联同展厅关联”。截图中的关系理由由推荐项字段提供卡片仍保留神兽名称、分类和出没地等图鉴信息。空结果和异常结果如何处理推荐列表为空时面板不渲染占位卡片目标节点找不到对应图鉴时组件不能把任意默认神兽当作推荐结果。服务端返回的关系理由需要与可查询的图谱边保持一致不能用生成式文本替代候选召回。场景Repository 或组件处理页面可观察结果HTTP 请求成功且候选完整映射并按分数输出候选显示图谱推荐卡片与关联理由HTTP 请求失败切换到本地 Fallback仍可看到确定性候选与理由候选字段不完整过滤无效项不展示错误编号或空理由卡片没有可用候选返回空数组推荐面板不出现占位内容可按下面的顺序验收这条链路选择一个已收录神兽进入详情页滚动到“图谱推荐”确认至少一张候选卡片显示“关联”理由点击候选后图鉴详情切换到对应节点。断网或服务暂不可用时重复同一动作仍应得到本地候选和可读理由。为后续图谱召回预留替换点当前排序只使用神兽、区域和展厅等已登记关系分数的作用是让候选顺序稳定并不宣称它代表用户偏好。更完整的召回服务接入后可以继续返回相同的五个字段服务端负责把边类型、边权重和过滤条件压缩为graphReason与score客户端继续按nodeId取得图鉴实体并展示卡片。页面因此不需要跟随召回实现的变化反复调整。例如同展厅关系可以得到“同展厅关联”同区域关系可以得到“同区域关联”当同时满足多条关系时服务端或本地实现应选出优先级最高且可解释的一条而不是向卡片堆叠多句难以阅读的提示。理由文本应与实际候选来源一一对应如果候选由展厅边得出理由不能写成区域关联如果候选来自人工精选也应明确使用相应的关系类型。在接口演进中还需要保留三个约束。第一nodeType与nodeId组成目标节点的稳定身份不能只依赖显示名称。第二分数仅用于同一批候选排序客户端不把它当作跨版本的业务指标。第三空数组是合法结果代表当前节点没有可展示候选这比返回一个与当前节点无关的默认卡片更可靠。遵守这些约束后详情页、馆长页或其他入口都可以共享同一推荐面板同时保持关系说明的来源可追溯。对读者而言最直观的检查点是卡片的标题能打开正确神兽卡片下方的“关联”文字能解释本次候选来自哪条已登记关系两者应同时变化不能只更新其中一个。这样推荐结果既可继续浏览也能被快速复核。小结图谱推荐在这里是一条可替换的数据链路契约负责表达候选和理由Repository 负责切换 HTTP、Mock 与 FallbackArkUI 只展示已经返回的关系。先把理由固定在数据模型中后续无论接入更完整的图谱召回还是个性化排序详情页都能沿用同一套展示和跳转逻辑。参考HarmonyOS HTTP 数据请求官方指南。