import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql' import { ScalarObject } from '@things-factory/shell' import { TwinEngine } from '../../engine/index.js' import { digestAttentions } from '../../engine/attention-digest.js' /* * 주목 신호(Attention) 질의 — 현재 스냅샷의 요약된 "봐야 할 것". * 커널 computeAttentions() 파생: severity(ISA-18.2/IEC 62682) · state(OPC UA A&C) · * anchor(locationId/moverId/orderId = 공간 위치) · suggestedAction(조치 제안). kind 는 도메인 소유(무방언). * full StateSnapshot 대신 이 경량 목록을 AI read 도구·insight UI 가 소싱(대용량 덤프 회피). * (실시간 스트림은 twinState subscription 이 attentions 를 포함. 이건 one-shot '질의' 표면.) */ @Resolver() export class TwinAttentionQuery { @Query(returns => [ScalarObject], { description: 'Active attention signals (things to look at) derived from the current twin snapshot — severity (ISA-18.2), lifecycle state (OPC UA A&C), space anchor, and suggested action.' }) async twinAttentions( @Ctx() context: ResolverContext, @Arg('instanceId', { nullable: true }) instanceId?: string, /* * **공간으로 물으면 그 현장의 모든 렌즈에서 모아 답한다** — 한 현실을 WMS·MES·YMS 가 함께 비추므로 * "이 현장에서 봐야 할 것" 은 렌즈 하나로 답할 수 없다. 예전에는 화면이 인스턴스마다 물어 손으로 * 합쳤고, 그러면 정렬 규칙이 화면마다 갈린다. * * 경고는 **합집합이 그대로 뜻이 있는 유일한 렌즈**다. 예측은 합칠 수 없다(P50 두 개를 더할 수 없다) — * 그래서 예측은 여전히 인스턴스별로 답하고 화면이 나란히 놓는다. */ @Arg('spaceId', { nullable: true }) spaceId?: string /* * ── 자르는 인자는 여기 없다 (2026-09-19, 수석 아키텍트 판정) ────────────── * * `limit` 이 있었다. 자르면서 **몇 건을 잘랐는지는 답에 싣지 못했다** — 답이 맨 목록이라 실을 * 자리가 없었기 때문이다. 자를 수 있는 문이 잘린 수를 못 말하면, 스무 건만 받은 화면은 그것이 * 전부인지 이백 중 스무 건인지 구별할 수 없다. * * 자르려는 소비자는 `twinAttentionDigest` 를 쓴다 — 그 문은 `attentionTotal` 을 함께 답하고, * 자리별 색을 **전량을 근거로** 계산한다. 이 문은 자르지 않는다. */ ): Promise { if (spaceId) return (await TwinEngine.attentionsOfSpace(context.state.domain.id, spaceId)).attentions if (!instanceId || !TwinEngine.owns(context.state.domain.id, instanceId)) return [] // 테넌트 격리 // 라이브 커널 스냅샷(fresh) 우선 — snapshot()=resync() 는 캐시라 커맨드 델타 지연(recover 와 동일 원천 사용). const kernel = TwinEngine.kernel(context.state.domain.id, instanceId) const snap = kernel?.getSnapshot?.() ?? TwinEngine.snapshot(context.state.domain.id, instanceId) return snap?.attentions ?? [] } /** * 잘랐다면 **몇 건을 잘랐는지**와 자리 색 요약까지 — 목록만 주면 화면이 "N건 더" 를 말할 수 없다. * 자리별 최고 심각도는 **전량을 근거로** 계산한다(상위 N 으로 줄이면 20위 밖 신호를 가진 자리가 색을 잃는다). */ @Query(returns => ScalarObject, { description: 'Attention signals for a space (all its lenses) or one twin, with the total count and the per-location highest severity folded from all of them.' }) async twinAttentionDigest( @Ctx() context: ResolverContext, @Arg('spaceId', { nullable: true }) spaceId?: string, @Arg('instanceId', { nullable: true }) instanceId?: string, @Arg('limit', type => Int, { nullable: true }) limit?: number ): Promise { if (spaceId) return TwinEngine.attentionsOfSpace(context.state.domain.id, spaceId, limit ?? undefined) if (!instanceId || !TwinEngine.owns(context.state.domain.id, instanceId)) return { attentions: [], attentionTotal: 0, severityByLocation: {} } const kernel = TwinEngine.kernel(context.state.domain.id, instanceId) const snap = kernel?.getSnapshot?.() ?? TwinEngine.snapshot(context.state.domain.id, instanceId) return digestAttentions((snap?.attentions ?? []).map((a: any) => ({ ...a, instanceId })), limit ?? undefined) } }