/* * **AI 가 의견을 내는 자리** — 판정이 아니라 발언. * * ── 왜 필요한가 (2026-09-01, 사용자 지적) ──────────────────────────────── * 사용자가 물었다 — 「AI 자신은 결재/승인의 담당이 될 수 없나. 최소한 의견제시자라도 될 수 있지 않나.」 * * 승인자는 안 된다. 승인 기록의 값어치는 사고 뒤에 **책임을 물을 수 있는 것**이고, 「AI 가 승인했다」로는 * 아무에게도 묻지 못한다. 그리고 낸 쪽이 승인하면 두 사람이 아니다 — 자기 승인을 막는 규칙과 같은 이유다. * * 의견은 내야 한다. `twinNow` 로 「도금 대기가 여섯 배다」는 사실을 승인 화면에 실었지만, 그건 계산값이다. * 「그러니 더 만들면 대기가 늘어난다」가 의견이고 그 자리가 없었다. * * ── 언제 묻나 — 승인 화면을 열 때다 ───────────────────────────────────── * 결재선에 올릴 때 묻지 않는다. 그 자리는 트랜잭션 안이고(`raiseTwinCommand` 의 `@transaction`), * 거기서 LLM 을 기다리면 **DB 연결 하나를 몇 초 붙잡는다**(sqlite 는 연결이 하나다). * * 그리고 화면을 열 때 묻는 것이 더 옳다. 승인하는 사람은 **지금** 판단하고, 올린 시각의 의견은 낡았다. * * ── 판정에 쓰지 않는다 ─────────────────────────────────────────────────── * 이 값을 읽어 무엇을 막는 코드가 없다. 승인·거절은 사람이 한다. AI 가 반대해도 승인되고, 동의해도 * 거절된다. 그 사실이 코드에 드러나 있어야 한다 — 판정에 쓰면 AI 가 사실상 승인자가 된다. */ import type { StoredCommand } from './command-dispatcher.js' import type { TwinNow } from './twin-now.js' /** * AI 가 어떤 태도를 냈나 — **세 갈래여야 한다.** * * 「모르겠다」가 없으면 모를 때 동의로 답한다. 그리고 승인자가 그것을 「AI 가 괜찮다고 했다」로 읽는다. * 자동 승인 편향이 거기서 시작한다. */ export type AdvisorStance = 'object' | 'concur' | 'unsure' export interface AdvisorOpinion { stance: AdvisorStance /** 사람이 읽는 문장. AI 가 쓴 그대로 — 우리가 다듬지 않는다(다듬으면 누가 말한 것인지 흐려진다). */ text: string /** 무엇을 보고 말했나 — 주의 식별자·값. 승인자가 근거를 되짚을 수 있게. */ basis?: string[] } /** 의견을 내는 자리 — 앱이 채운다(headless-twin 은 `twin-ai` 를 부르지 않는다). */ export interface ActuationAdvisor { /** 화면이 출처를 말해야 한다 — 「AI 의견」. 사람의 의견과 섞이면 안 된다. */ label: string /** * 이 조치에 대해 한마디. * * 실패·시간초과면 `undefined` 를 돌려준다. 지어내지 않는다. */ advise(input: { /* * The tenant the command belongs to. Required: the advisor's tools read the twin through it, and without it * every tool answers "no domain context" while the opinion is still written from the prompt alone * (2026-09-17 — the twin app passed nothing because this input had no place for it). */ domainId: string command: StoredCommand twinNow: TwinNow locale?: string }): Promise } let advisor: ActuationAdvisor | undefined /** 앱이 부팅에서 한 번 부른다. */ export function setActuationAdvisor(next: ActuationAdvisor): void { advisor = next } export function actuationAdvisor(): ActuationAdvisor | undefined { return advisor } /** 의견을 물은 결과 — **없음도 사실이다.** */ export interface AdvisorResult { /** 누가 말했나. 없으면 아무도 말하지 않았다. */ by?: string opinion?: AdvisorOpinion /** * 의견이 없으면 왜 없나. * * 빈칸을 「AI 가 반대하지 않았다」로 읽으면 안 된다. 화면이 이 문장을 그대로 보여야 한다. */ absent?: string } /** 몇 초까지 기다리나 — 승인 화면이 이것 때문에 안 열리면 안 된다. */ export const ADVISOR_BUDGET_MS = 8_000 /** * 의견을 묻는다 — **없으면 없다고 답한다.** * * 실패를 삼키지 않는다. AI 가 죽어 있어서 의견이 없는 것과 AI 가 반대할 것이 없어서 조용한 것은 다른 * 사실이고, 승인하는 사람에게 그 차이가 크다. */ export async function askAdvisor(args: { domainId: string command: StoredCommand twinNow: TwinNow locale?: string budgetMs?: number }): Promise { const a = advisor if (!a) return { absent: 'AI 의견을 내는 자리가 설치되지 않았습니다' } const budget = args.budgetMs ?? ADVISOR_BUDGET_MS let timer: any try { const opinion = await Promise.race([ a.advise({ domainId: args.domainId, command: args.command, twinNow: args.twinNow, ...(args.locale ? { locale: args.locale } : {}) }), new Promise(resolve => { timer = setTimeout(() => resolve(TIMED_OUT as any), budget) }) ]) if ((opinion as any) === TIMED_OUT) { return { by: a.label, absent: `${Math.round(budget / 1000)}초 안에 답이 오지 않았습니다` } } if (!opinion?.text?.trim()) return { by: a.label, absent: 'AI 가 할 말이 없다고 답했습니다' } return { by: a.label, opinion } } catch (e: any) { return { by: a.label, absent: `의견을 받지 못했습니다 — ${String(e?.message ?? e)}` } } finally { if (timer) clearTimeout(timer) } } /** 시간초과를 값으로 구별한다 — `undefined` 로 두면 「할 말 없음」과 섞인다. */ const TIMED_OUT = Symbol('advisor-timeout')