@things-factory/ai-assistant 10.1.39 → 10.1.40
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.
- package/client/components/assistant-chat.ts +30 -1
- package/client/utils/assistant-request-context.ts +32 -0
- package/dist-client/components/assistant-chat.d.ts +12 -0
- package/dist-client/components/assistant-chat.js +24 -1
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-request-context.d.ts +23 -0
- package/dist-client/utils/assistant-request-context.js +25 -0
- package/dist-client/utils/assistant-request-context.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.js +1 -1
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-anchor.d.ts +5 -1
- package/dist-server/service/chat-session/session-anchor.js +23 -1
- package/dist-server/service/chat-session/session-anchor.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/chat-session/chat-session.ts +1 -1
- package/server/service/chat-session/session-anchor.ts +26 -1
- package/server/service/session-anchor.test.ts +55 -1
- package/test/assistant-request-context.test.ts +41 -1
|
@@ -5,6 +5,29 @@ export interface AssistantRequestContext {
|
|
|
5
5
|
hostContext?: Record<string, unknown>;
|
|
6
6
|
}
|
|
7
7
|
export declare function assistantAutoAskKey(text: string, requestId?: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Whether a draft handed in from outside fills the input now — the pair of `autoAsk` that does not send.
|
|
10
|
+
*
|
|
11
|
+
* A host sometimes wants a sentence placed in the input for the person to edit and send themselves
|
|
12
|
+
* ("new twin → AI" in twin's instance list). Four rules, each a thing a person would notice:
|
|
13
|
+
* · once per request — a re-render must not fill it again after the person cleared or changed it
|
|
14
|
+
* · never over what the person is typing — their text is theirs
|
|
15
|
+
* · when `autoAsk` comes for the same request, it wins — sending is the clearer intent
|
|
16
|
+
* · no draft, nothing to do
|
|
17
|
+
* Returns the key to remember when the draft was consumed (filled, or deliberately skipped), so a
|
|
18
|
+
* skipped draft does not surface later by surprise.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assistantDraftDecision(input: {
|
|
21
|
+
draft?: string;
|
|
22
|
+
draftRequestId?: string;
|
|
23
|
+
drafted?: string;
|
|
24
|
+
typed: string;
|
|
25
|
+
autoAsk?: string;
|
|
26
|
+
autoAskRequestId?: string;
|
|
27
|
+
}): {
|
|
28
|
+
fill: boolean;
|
|
29
|
+
consumed?: string;
|
|
30
|
+
};
|
|
8
31
|
export declare function assistantRecentSessions<T extends {
|
|
9
32
|
id: string;
|
|
10
33
|
}>(sessions: T[], selected: string, limit: number): T[];
|
|
@@ -9,6 +9,31 @@ export function assistantModelFingerprint(model) {
|
|
|
9
9
|
export function assistantAutoAskKey(text, requestId) {
|
|
10
10
|
return requestId ? JSON.stringify([requestId, text]) : text;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether a draft handed in from outside fills the input now — the pair of `autoAsk` that does not send.
|
|
14
|
+
*
|
|
15
|
+
* A host sometimes wants a sentence placed in the input for the person to edit and send themselves
|
|
16
|
+
* ("new twin → AI" in twin's instance list). Four rules, each a thing a person would notice:
|
|
17
|
+
* · once per request — a re-render must not fill it again after the person cleared or changed it
|
|
18
|
+
* · never over what the person is typing — their text is theirs
|
|
19
|
+
* · when `autoAsk` comes for the same request, it wins — sending is the clearer intent
|
|
20
|
+
* · no draft, nothing to do
|
|
21
|
+
* Returns the key to remember when the draft was consumed (filled, or deliberately skipped), so a
|
|
22
|
+
* skipped draft does not surface later by surprise.
|
|
23
|
+
*/
|
|
24
|
+
export function assistantDraftDecision(input) {
|
|
25
|
+
if (!input.draft)
|
|
26
|
+
return { fill: false };
|
|
27
|
+
const key = assistantAutoAskKey(input.draft, input.draftRequestId);
|
|
28
|
+
if (key === input.drafted)
|
|
29
|
+
return { fill: false };
|
|
30
|
+
const sameRequestAsked = !!input.autoAsk && !!input.draftRequestId && input.autoAskRequestId === input.draftRequestId;
|
|
31
|
+
if (sameRequestAsked)
|
|
32
|
+
return { fill: false, consumed: key };
|
|
33
|
+
if (input.typed.trim())
|
|
34
|
+
return { fill: false, consumed: key };
|
|
35
|
+
return { fill: true, consumed: key };
|
|
36
|
+
}
|
|
12
37
|
export function assistantRecentSessions(sessions, selected, limit) {
|
|
13
38
|
const count = Math.max(1, Math.floor(limit) || 5);
|
|
14
39
|
const recent = sessions.slice(0, count);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"assistant-request-context.js","sourceRoot":"","sources":["../../client/utils/assistant-request-context.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,MAAM,UAAU,yBAAyB,CAAC,KAAc;IACtD,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACzC,MAAM,SAAS,GAAG,CAAC,KAAU,EAAO,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC;QAChF,CAAC,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;IACvI,OAAO,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;AACzC,CAAC;AAKD,MAAM,UAAU,mBAAmB,CAAC,IAAY,EAAE,SAAkB;IAClE,OAAO,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;AAC7D,CAAC;AACD,MAAM,UAAU,uBAAuB,CAA2B,QAAa,EAAE,QAAgB,EAAE,KAAa;IAC9G,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAA;IACjD,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAA;IACvC,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;IAChE,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC;QAAE,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,MAAM,CAAA;IAClG,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Exact deterministic model signature, not a security hash. Only plain JSON models are supported. */\nexport function assistantModelFingerprint(model: unknown): string | undefined {\n if (model === undefined) return undefined\n const normalize = (value: any): any => Array.isArray(value) ? value.map(normalize)\n : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value\n return JSON.stringify(normalize(model))\n}\nexport interface AssistantRequestContext {\n systemPrompt?: string\n hostContext?: Record<string, unknown>\n}\nexport function assistantAutoAskKey(text: string, requestId?: string): string {\n return requestId ? JSON.stringify([requestId, text]) : text\n}\nexport function assistantRecentSessions<T extends { id: string }>(sessions: T[], selected: string, limit: number): T[] {\n const count = Math.max(1, Math.floor(limit) || 5)\n const recent = sessions.slice(0, count)\n const active = sessions.find(session => session.id === selected)\n if (active && !recent.some(session => session.id === selected)) recent[recent.length - 1] = active\n return recent\n}\n"]}
|
|
1
|
+
{"version":3,"file":"assistant-request-context.js","sourceRoot":"","sources":["../../client/utils/assistant-request-context.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,MAAM,UAAU,yBAAyB,CAAC,KAAc;IACtD,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IACzC,MAAM,SAAS,GAAG,CAAC,KAAU,EAAO,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC;QAChF,CAAC,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;IACvI,OAAO,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;AACzC,CAAC;AAKD,MAAM,UAAU,mBAAmB,CAAC,IAAY,EAAE,SAAkB;IAClE,OAAO,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;AAC7D,CAAC;AACD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,sBAAsB,CAAC,KAOtC;IACC,IAAI,CAAC,KAAK,CAAC,KAAK;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;IAExC,MAAM,GAAG,GAAG,mBAAmB,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,cAAc,CAAC,CAAA;IAClE,IAAI,GAAG,KAAK,KAAK,CAAC,OAAO;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;IAEjD,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC,KAAK,CAAC,cAAc,IAAI,KAAK,CAAC,gBAAgB,KAAK,KAAK,CAAC,cAAc,CAAA;IACrH,IAAI,gBAAgB;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAA;IAE3D,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAA;IAE7D,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAA;AACtC,CAAC;AACD,MAAM,UAAU,uBAAuB,CAA2B,QAAa,EAAE,QAAgB,EAAE,KAAa;IAC9G,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAA;IACjD,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAA;IACvC,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;IAChE,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,QAAQ,CAAC;QAAE,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,MAAM,CAAA;IAClG,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["/** Exact deterministic model signature, not a security hash. Only plain JSON models are supported. */\nexport function assistantModelFingerprint(model: unknown): string | undefined {\n if (model === undefined) return undefined\n const normalize = (value: any): any => Array.isArray(value) ? value.map(normalize)\n : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value\n return JSON.stringify(normalize(model))\n}\nexport interface AssistantRequestContext {\n systemPrompt?: string\n hostContext?: Record<string, unknown>\n}\nexport function assistantAutoAskKey(text: string, requestId?: string): string {\n return requestId ? JSON.stringify([requestId, text]) : text\n}\n/**\n * Whether a draft handed in from outside fills the input now — the pair of `autoAsk` that does not send.\n *\n * A host sometimes wants a sentence placed in the input for the person to edit and send themselves\n * (\"new twin → AI\" in twin's instance list). Four rules, each a thing a person would notice:\n * · once per request — a re-render must not fill it again after the person cleared or changed it\n * · never over what the person is typing — their text is theirs\n * · when `autoAsk` comes for the same request, it wins — sending is the clearer intent\n * · no draft, nothing to do\n * Returns the key to remember when the draft was consumed (filled, or deliberately skipped), so a\n * skipped draft does not surface later by surprise.\n */\nexport function assistantDraftDecision(input: {\n draft?: string\n draftRequestId?: string\n drafted?: string\n typed: string\n autoAsk?: string\n autoAskRequestId?: string\n}): { fill: boolean; consumed?: string } {\n if (!input.draft) return { fill: false }\n\n const key = assistantAutoAskKey(input.draft, input.draftRequestId)\n if (key === input.drafted) return { fill: false }\n\n const sameRequestAsked = !!input.autoAsk && !!input.draftRequestId && input.autoAskRequestId === input.draftRequestId\n if (sameRequestAsked) return { fill: false, consumed: key }\n\n if (input.typed.trim()) return { fill: false, consumed: key }\n\n return { fill: true, consumed: key }\n}\nexport function assistantRecentSessions<T extends { id: string }>(sessions: T[], selected: string, limit: number): T[] {\n const count = Math.max(1, Math.floor(limit) || 5)\n const recent = sessions.slice(0, count)\n const active = sessions.find(session => session.id === selected)\n if (active && !recent.some(session => session.id === selected)) recent[recent.length - 1] = active\n return recent\n}\n"]}
|
|
@@ -54,7 +54,7 @@ tslib_1.__decorate([
|
|
|
54
54
|
(0, typeorm_1.Column)({ nullable: true }),
|
|
55
55
|
(0, type_graphql_1.Field)({
|
|
56
56
|
nullable: true,
|
|
57
|
-
description: "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
|
|
57
|
+
description: "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
|
|
58
58
|
}),
|
|
59
59
|
tslib_1.__metadata("design:type", String)
|
|
60
60
|
], ChatSession.prototype, "anchorType", void 0);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })\n lastMessageAt?: Date\n\n /**\n * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).\n *\n * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`\n * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야\n * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로\n * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는\n * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.\n */\n @Column({ type: 'varchar', length: 64, nullable: true })\n @Field({ nullable: true, description: \"Which conversation this session is — the registered mode kind. Written by the server, never sent by the client.\" })\n kind?: string\n\n /**\n * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.\n *\n * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.\n * `private` 참여자만 읽는다.\n *\n * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로\n * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을\n * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면\n * 사적으로 오간 원문이 소급해서 공개된다.\n *\n * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.\n *\n * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.\n */\n @Column({ type: 'varchar', length: 16, default: 'open' })\n @Field({ description: \"Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only).\" })\n visibility!: string\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n /**\n * 그 미리보기가 지나온 문 — ADR-0073 판정 1.\n *\n * 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.\n * 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을\n * 함께 옮겨 적고, 목록도 같은 관문을 지난다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })\n lastMessageDoors?: string[]\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /**\n * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.\n *\n * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을\n * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로\n * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })\n summaryUpToMessageId?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n /*\n * ── 보드 패치 관계를 끊었다 (2026-09-10) ──────────────────────────────────\n * 여기 `@OneToMany(PatchEntry)` 가 있었다. 그래서 **대화 세션이 보드 편집기를 알아야** 했고,\n * 세션 계열을 중립 패키지로 옮기려는 순간 `ai-assistant → board-ai → ai-assistant` 순환이\n * 된다.\n *\n * 끊어도 잃는 것이 없다 — 이 관계를 **자료로 읽는 자리가 하나도 없었다**(전 저장소에서\n * `session.patches` 를 읽는 코드 0 건). 유일한 쓰임이 `PatchEntry` 쪽의 역방향 선언 한 줄이었고,\n * TypeORM 은 역방향 없이도 `@ManyToOne` 을 만든다. 패치는 `sessionId` 로 찾는다\n * (`chatPatches` 가 이미 그렇게 한다).\n *\n * 방향은 남는다: **보드 패치가 세션을 안다. 세션은 보드 패치를 모른다.** 종속이 한쪽이면\n * 중립 쪽이 옮겨질 수 있다.\n */\n}\n"]}
|
|
1
|
+
{"version":3,"file":"chat-session.js","sourceRoot":"","sources":["../../../server/service/chat-session/chat-session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ChatSession — Board 와 결합되는 AI 협력 세션.\n *\n * 한 보드에 여러 세션 가능 — DB 레벨 unique 제약 없음.\n *\n * 향후 폭증할 세션 분화 차원 (예: thread / 사용자별 / 컨텍스트별 design vs ops /\n * archived / forked from clone / agent별) 을 미리 차단하지 않기 위해 구조 자체는 1:N\n * 으로 열어둠. 단일 세션 정책 (현재 UX) 은 app 레벨 idempotent 로직 — `startBoardAISession`\n * 이 boardId 매칭 시 기존 세션 반환, `chatSessionByBoard` 가 첫 매칭 반환.\n *\n * 미래에 다중 세션 활성화 시 discriminator 컬럼 (kind / name / threadId / creatorId 등)\n * 을 그때 추가. 마이그레이션 부담 최소화 — 현재는 unique 만 풀어 자리 마련.\n *\n * board-service 의 Board 가 owning side 가 될 예정 (ChatSession FK).\n */\nimport {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n OneToMany,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, ID, ObjectType } from 'type-graphql'\n\nimport { Domain } from '@things-factory/shell'\nimport { User } from '@things-factory/auth-base'\nimport { config } from '@things-factory/env'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\n\nconst ORMCONFIG = config.get('ormconfig', {})\nconst DATABASE_TYPE = ORMCONFIG.type\n\n@Entity()\n// 인덱스는 조회 성능용으로 유지 (chatSessionByBoard 가 [domain, boardId] 로 자주 쿼리).\n// unique 제약은 의도적으로 제거 — 한 보드에 여러 세션을 미래에 허용하기 위함.\n@Index('ix_chat_session_1', (session: ChatSession) => [session.domain, session.boardId])\n// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.\n@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])\n@ObjectType({\n description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'\n})\nexport class ChatSession {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true })\n domain?: Domain\n\n @RelationId((session: ChatSession) => session.domain)\n domainId?: string\n\n /** Board.id 와 연결. Board 가 owning side 가 될 예정. 1:N — 한 보드에 여러 세션 가능.\n *\n * **앵커 일반화 이후에도 유지되는 하위호환 컬럼**이다(제거하지 말 것). 앵커가 board 인 세션은\n * anchorId 와 함께 이 컬럼에도 같은 값을 쓴다(dual-write) — 기존 조회/인덱스/소비자가 그대로 동작한다. */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n 'Connected Board id. Multiple sessions per board allowed (future: threads / per-user / contexts). Kept for backward compatibility — board-anchored sessions dual-write this alongside anchorId.'\n })\n boardId?: string\n\n /**\n * 대화가 매달린 대상의 **종류**. 세션은 보드에만 매달리지 않는다 —\n * 저작 대화는 보드에, 운영 대화는 공간(사이트)에 매달린다. 앵커가 대화의 성격과 이력 경계를 정한다.\n *\n * 'board' | 'space' | 'instance' | 'none'. 미설정(기존 데이터)은 boardId 가 있으면 board 로 읽는다 —\n * 백필 마이그레이션 없이 lazy 하위호환(컬럼 추가만).\n */\n @Column({ nullable: true })\n @Field({\n nullable: true,\n description:\n \"What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | 'none'. Absent on legacy rows — treated as 'board' when boardId is present.\"\n })\n anchorType?: string\n\n /** 앵커 대상의 id(보드 id · 공간 id · 인스턴스 id). anchorType 과 함께 의미를 갖는다. */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the anchored target (board id / space id / instance id).' })\n anchorId?: string\n\n /**\n * 세션의 사용자 정의 이름 (탭 레이블 / 세션 식별 표시용).\n *\n * nullable — 미설정 시 UI 가 fallback 라벨 (\"세션 N\" 또는 createdAt 기반) 사용. 사용자가\n * 다중 세션 운영 시 의미 부여 (예: \"초기 설계\", \"운영 시뮬레이션\", \"사용자A 의 사적 세션\").\n */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'User-given name of this session (tab label / identification).' })\n name?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who created this session.' })\n creator?: User\n\n @RelationId((session: ChatSession) => session.creator)\n creatorId?: string\n\n @ManyToOne(type => User)\n @Field(type => User, { nullable: true, description: 'User who last updated this session.' })\n updater?: User\n\n @RelationId((session: ChatSession) => session.updater)\n updaterId?: string\n\n /** 토큰 절감용 — 오래된 메시지를 LLM 으로 압축한 요약. Phase 2 에서 작성. */\n /**\n * 마지막 메시지 시각 — **비정규화**. 목록 정렬(최근 활동순)과 안 읽음 판정의 기준.\n *\n * 왜 비정규화인가: 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계 쿼리가 붙고,\n * 이 프레임워크는 5개 DB 드라이버를 지원해야 한다. 세션 컬럼 하나로 두면 정렬·비교가 값싸게 끝나고\n * 드라이버 차이가 없다. 메시지를 저장할 때 함께 갱신한다(activityPatch).\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })\n lastMessageAt?: Date\n\n /**\n * 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).\n *\n * **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`\n * 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야\n * 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로\n * 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는\n * 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.\n */\n @Column({ type: 'varchar', length: 64, nullable: true })\n @Field({ nullable: true, description: \"Which conversation this session is — the registered mode kind. Written by the server, never sent by the client.\" })\n kind?: string\n\n /**\n * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.\n *\n * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.\n * `private` 참여자만 읽는다.\n *\n * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로\n * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을\n * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면\n * 사적으로 오간 원문이 소급해서 공개된다.\n *\n * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.\n *\n * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.\n */\n @Column({ type: 'varchar', length: 16, default: 'open' })\n @Field({ description: \"Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only).\" })\n visibility!: string\n\n /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */\n @Column({ nullable: true, length: 200 })\n @Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })\n lastMessagePreview?: string\n\n /**\n * 그 미리보기가 지나온 문 — ADR-0073 판정 1.\n *\n * 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.\n * 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을\n * 함께 옮겨 적고, 목록도 같은 관문을 지난다.\n */\n @Column({ type: 'simple-array', nullable: true })\n @Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })\n lastMessageDoors?: string[]\n\n @Column({\n nullable: true,\n type:\n DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'\n ? 'longtext'\n : DATABASE_TYPE == 'oracle'\n ? 'clob'\n : DATABASE_TYPE == 'mssql'\n ? 'nvarchar'\n : 'text',\n length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined\n })\n @Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })\n lastSummary?: string\n\n /**\n * `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.\n *\n * 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을\n * 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로\n * 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })\n summaryUpToMessageId?: string\n\n /** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */\n @Column({ nullable: true })\n @Field({ nullable: true })\n aiClientId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true })\n updatedAt?: Date\n\n @DeleteDateColumn()\n deletedAt?: Date\n\n // ── 관계 (TypeORM 만, GraphQL 노출은 별도 query 통해 페이징) ──\n @OneToMany(type => ChatMessage, message => message.session)\n messages?: ChatMessage[]\n\n /*\n * ── 보드 패치 관계를 끊었다 (2026-09-10) ──────────────────────────────────\n * 여기 `@OneToMany(PatchEntry)` 가 있었다. 그래서 **대화 세션이 보드 편집기를 알아야** 했고,\n * 세션 계열을 중립 패키지로 옮기려는 순간 `ai-assistant → board-ai → ai-assistant` 순환이\n * 된다.\n *\n * 끊어도 잃는 것이 없다 — 이 관계를 **자료로 읽는 자리가 하나도 없었다**(전 저장소에서\n * `session.patches` 를 읽는 코드 0 건). 유일한 쓰임이 `PatchEntry` 쪽의 역방향 선언 한 줄이었고,\n * TypeORM 은 역방향 없이도 `@ManyToOne` 을 만든다. 패치는 `sessionId` 로 찾는다\n * (`chatPatches` 가 이미 그렇게 한다).\n *\n * 방향은 남는다: **보드 패치가 세션을 안다. 세션은 보드 패치를 모른다.** 종속이 한쪽이면\n * 중립 쪽이 옮겨질 수 있다.\n */\n}\n"]}
|
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
* space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
|
|
6
6
|
* instance 트윈 하나에 대한 대화
|
|
7
7
|
* domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
|
|
8
|
+
* subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared
|
|
9
|
+
* frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead
|
|
10
|
+
* would put every subject's conversations in one bucket and lose the per-subject list and
|
|
11
|
+
* history boundary (ruled 2026-09-24).
|
|
8
12
|
* none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
|
|
9
13
|
*/
|
|
10
|
-
export declare const SESSION_ANCHOR_TYPES: readonly ["board", "figure", "application", "space", "instance", "domain", "none"];
|
|
14
|
+
export declare const SESSION_ANCHOR_TYPES: readonly ["board", "figure", "application", "space", "instance", "domain", "subject", "none"];
|
|
11
15
|
export type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number];
|
|
12
16
|
export declare function isSessionAnchorType(value: unknown): value is SessionAnchorType;
|
|
13
17
|
/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
|
|
@@ -55,11 +55,15 @@ exports.anchorWhere = anchorWhere;
|
|
|
55
55
|
* space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
|
|
56
56
|
* instance 트윈 하나에 대한 대화
|
|
57
57
|
* domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
|
|
58
|
+
* subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared
|
|
59
|
+
* frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead
|
|
60
|
+
* would put every subject's conversations in one bucket and lose the per-subject list and
|
|
61
|
+
* history boundary (ruled 2026-09-24).
|
|
58
62
|
* none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
|
|
59
63
|
*/
|
|
60
64
|
// Figure-specific history requested explicitly: reuse the existing varchar
|
|
61
65
|
// anchor columns, without writing Figure ids into the legacy boardId column.
|
|
62
|
-
exports.SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'];
|
|
66
|
+
exports.SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'subject', 'none'];
|
|
63
67
|
function isSessionAnchorType(value) {
|
|
64
68
|
return typeof value === 'string' && exports.SESSION_ANCHOR_TYPES.includes(value);
|
|
65
69
|
}
|
|
@@ -74,6 +78,22 @@ function assertAnchorType(anchorType, where) {
|
|
|
74
78
|
}
|
|
75
79
|
return type;
|
|
76
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* A `subject` anchor id is `<axis>:<itemId>`, the item being that row's uuid (ruled 2026-09-24).
|
|
83
|
+
*
|
|
84
|
+
* Checked because the shape is the whole contract: a screen that passes what it shows — an EPC like
|
|
85
|
+
* `urn:epc:id:sgtin:…`, or an order's name — would store a pointer to nothing stable. A name changes
|
|
86
|
+
* and the conversation is orphaned; an EPC splits on its own colons. Refused here, on write and on
|
|
87
|
+
* read, rather than stored and never found.
|
|
88
|
+
*/
|
|
89
|
+
const SUBJECT_ANCHOR_ID = /^[a-z][a-z0-9-]*:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
90
|
+
function assertSubjectAnchorId(type, id, where) {
|
|
91
|
+
if (type !== 'subject' || !id || SUBJECT_ANCHOR_ID.test(id))
|
|
92
|
+
return;
|
|
93
|
+
throw new Error(`[ai-assistant/session-anchor] ${where}: '${id}' is not a subject anchor. ` +
|
|
94
|
+
"A subject anchor is '<axis>:<itemId>' — the 360 frame's axis word and the row's uuid, " +
|
|
95
|
+
"for example 'items:6f1c…'. Resolve what the screen shows (an EPC, a name) to the row's uuid first.");
|
|
96
|
+
}
|
|
77
97
|
/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
|
|
78
98
|
function anchorColumns(anchorType, anchorId) {
|
|
79
99
|
const type = assertAnchorType(anchorType, 'anchorColumns');
|
|
@@ -84,6 +104,7 @@ function anchorColumns(anchorType, anchorId) {
|
|
|
84
104
|
'given — the session would not be found by its own anchor and every call would create another. ' +
|
|
85
105
|
"Use 'none' for a conversation that is deliberately not addressable by anchor.");
|
|
86
106
|
}
|
|
107
|
+
assertSubjectAnchorId(type, id, 'anchorColumns');
|
|
87
108
|
return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined };
|
|
88
109
|
}
|
|
89
110
|
/**
|
|
@@ -104,6 +125,7 @@ function anchorColumns(anchorType, anchorId) {
|
|
|
104
125
|
*/
|
|
105
126
|
function anchorWhere(domainId, anchorType, anchorId, kind) {
|
|
106
127
|
const type = assertAnchorType(anchorType, 'anchorWhere');
|
|
128
|
+
assertSubjectAnchorId(type, anchorId, 'anchorWhere');
|
|
107
129
|
const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } };
|
|
108
130
|
return kind ? { ...where, kind } : where;
|
|
109
131
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-anchor.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-anchor.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;;;
|
|
1
|
+
{"version":3,"file":"session-anchor.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-anchor.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;;;AAqBH,kDAEC;AAoCD,sCAgBC;AAkBD,kCAKC;AAhGD;;;;;;;;;;;;GAYG;AACH,2EAA2E;AAC3E,6EAA6E;AAChE,QAAA,oBAAoB,GAAG,CAAC,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAU,CAAA;AAIjI,SAAgB,mBAAmB,CAAC,KAAc;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,4BAA0C,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;AACjG,CAAC;AAED,SAAS,gBAAgB,CAAC,UAA8B,EAAE,KAAa;IACrE,MAAM,IAAI,GAAG,UAAU,IAAI,MAAM,CAAA;IACjC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,6BAA6B,KAAK,MAAM,IAAI,2BAA2B;YACrE,YAAY,4BAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI;YAChD,gGAAgG;YAChG,2EAA2E;YAC3E,4EAA4E,CAC/E,CAAA;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,iBAAiB,GAAG,iFAAiF,CAAA;AAE3G,SAAS,qBAAqB,CAAC,IAAuB,EAAE,EAAsB,EAAE,KAAa;IAC3F,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,EAAE,IAAI,iBAAiB,CAAC,IAAI,CAAC,EAAE,CAAC;QAAE,OAAM;IACnE,MAAM,IAAI,KAAK,CACb,iCAAiC,KAAK,MAAM,EAAE,6BAA6B;QACzE,wFAAwF;QACxF,oGAAoG,CACvG,CAAA;AACH,CAAC;AAED,oEAAoE;AACpE,SAAgB,aAAa,CAC3B,UAA8B,EAC9B,QAA4B;IAE5B,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,eAAe,CAAC,CAAA;IAC1D,MAAM,EAAE,GAAG,QAAQ,IAAI,SAAS,CAAA;IAChC,IAAI,IAAI,KAAK,MAAM,IAAI,CAAC,EAAE,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,oDAAoD,IAAI,qBAAqB;YAC3E,+FAA+F;YAC/F,gGAAgG;YAChG,+EAA+E,CAClF,CAAA;IACH,CAAC;IACD,qBAAqB,CAAC,IAAI,EAAE,EAAE,EAAE,eAAe,CAAC,CAAA;IAChD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,EAAE,CAAA;AACvF,CAAC;AAED;;;;;;GAMG;AACH;;;;;;;;GAQG;AACH,SAAgB,WAAW,CAAC,QAAgB,EAAE,UAAkB,EAAE,QAAgB,EAAE,IAAa;IAC/F,MAAM,IAAI,GAAG,gBAAgB,CAAC,UAAU,EAAE,aAAa,CAAC,CAAA;IACxD,qBAAqB,CAAC,IAAI,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAA;IACpD,MAAM,KAAK,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAA;IAC3I,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAA;AAC1C,CAAC","sourcesContent":["/*\n * 세션 앵커 순수 로직 — 리졸버와 분리.\n *\n * chat-session-resolver 는 type-graphql 의 **매개변수 데코레이터**(@Arg)를 쓰므로 jest 의 babel\n * 파서가 읽지 못한다. 순수 함수를 여기 두면 단위 테스트가 가능하다(chat-echo-dedup ·\n * chat-input-builder 와 같은 관례).\n *\n * **앵커란**: 대화가 무엇에 매달려 있는지. 저작 대화는 보드에, 운영 대화는 공간(사이트와 그 공간에서\n * 도는 트윈들)에 매달린다. 앵커가 대화의 성격과 **이력 경계**를 정한다 — 보드 편집 이력에 운영\n * 문답이 끼지 않아야 하고, 그 반대도 마찬가지다.\n *\n * **하위호환**: 기존 행은 앵커 컬럼이 비어 있고 boardId 만 있다. 백필하지 않고 **읽을 때** board\n * 앵커로 간주한다(컬럼 추가만으로 끝내기 위함). 보드 앵커 세션은 anchorId 와 boardId 를 함께\n * 쓴다(dual-write) — 기존 조회·인덱스·소비자(operato-board 등)가 그대로 동작한다.\n *\n * ── Why this file validates instead of casting (2026-09-09) ──────────────────────\n * `anchorColumns` used to do `(anchorType || 'none') as SessionAnchorType`. That is a cast, not a\n * check: the type existed and nothing enforced it, so any string reached a nullable varchar and\n * was stored. The plant lane was about to add a product and stopped to ask which value to write,\n * which is the only reason this was noticed — the column would have accepted whatever it sent.\n *\n * A wrong value here is not a wrong value in one row. The anchor decides which conversations are\n * listed together and which history a reply may see, so a typo silently creates a second,\n * invisible bucket: the session is written, the inbox query for the real anchor does not match\n * it, and the conversation is simply gone from the list with no error anywhere.\n *\n * Two rules follow, and both are refusals rather than defaults:\n *\n * · An unknown anchor type throws. A default of 'none' would be worse than a typo, because it\n * turns \"you named something that does not exist\" into \"this conversation is about nothing\".\n * · Every anchor except 'none' requires an id. An anchor is a pointer; `('space', '')` points\n * nowhere, and `anchorWhere` would then look for an empty string while `anchorColumns` stored\n * NULL — the lookup misses its own row and every call creates another session.\n *\n * 'none' therefore means exactly one thing: **not addressable by anchor.** It is the UI state for\n * \"nothing picked yet\" (twin's dock refuses to create a session while it holds 'none'), not a\n * value a product writes because it has nothing narrower to point at. A product whose subject is\n * the whole tenant anchors to 'domain' and holds the domain id — that conversation is listable and\n * has a history boundary, and \"unanchored\" has neither.\n *\n * Adding a value to this set is an architect's call, not a lane's. A new anchor type means a new\n * kind of subject a conversation can be about, and it is stored — once rows carry it, renaming it\n * orphans them.\n */\n\n/**\n * 대화가 매달릴 수 있는 것. **저장되는 값이므로 늘리는 것은 아키텍트 판단이다.**\n *\n * board 보드 저작 대화 — 그 보드의 편집 이력\n * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들\n * instance 트윈 하나에 대한 대화\n * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때\n * subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared\n * frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead\n * would put every subject's conversations in one bucket and lose the per-subject list and\n * history boundary (ruled 2026-09-24).\n * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다\n */\n// Figure-specific history requested explicitly: reuse the existing varchar\n// anchor columns, without writing Figure ids into the legacy boardId column.\nexport const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'subject', 'none'] as const\n\nexport type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]\n\nexport function isSessionAnchorType(value: unknown): value is SessionAnchorType {\n return typeof value === 'string' && (SESSION_ANCHOR_TYPES as readonly string[]).includes(value)\n}\n\nfunction assertAnchorType(anchorType: string | undefined, where: string): SessionAnchorType {\n const type = anchorType || 'none'\n if (!isSessionAnchorType(type)) {\n throw new Error(\n `[board-ai/session-anchor] ${where}: '${type}' is not an anchor type. ` +\n `Allowed: ${SESSION_ANCHOR_TYPES.join(' · ')}. ` +\n 'The anchor decides which conversations are listed together and which history a reply may see, ' +\n 'so an unrecognised value would write a session the inbox can never find. ' +\n 'If a new kind of subject is needed, raise it — this set is stored in rows.'\n )\n }\n return type\n}\n\n/**\n * A `subject` anchor id is `<axis>:<itemId>`, the item being that row's uuid (ruled 2026-09-24).\n *\n * Checked because the shape is the whole contract: a screen that passes what it shows — an EPC like\n * `urn:epc:id:sgtin:…`, or an order's name — would store a pointer to nothing stable. A name changes\n * and the conversation is orphaned; an EPC splits on its own colons. Refused here, on write and on\n * read, rather than stored and never found.\n */\nconst SUBJECT_ANCHOR_ID = /^[a-z][a-z0-9-]*:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction assertSubjectAnchorId(type: SessionAnchorType, id: string | undefined, where: string): void {\n if (type !== 'subject' || !id || SUBJECT_ANCHOR_ID.test(id)) return\n throw new Error(\n `[ai-assistant/session-anchor] ${where}: '${id}' is not a subject anchor. ` +\n \"A subject anchor is '<axis>:<itemId>' — the 360 frame's axis word and the row's uuid, \" +\n \"for example 'items:6f1c…'. Resolve what the screen shows (an EPC, a name) to the row's uuid first.\"\n )\n}\n\n/** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */\nexport function anchorColumns(\n anchorType: string | undefined,\n anchorId: string | undefined\n): { anchorType: SessionAnchorType; anchorId?: string; boardId?: string } {\n const type = assertAnchorType(anchorType, 'anchorColumns')\n const id = anchorId || undefined\n if (type !== 'none' && !id) {\n throw new Error(\n `[board-ai/session-anchor] anchorColumns: anchor '${type}' was given no id. ` +\n 'An anchor is a pointer, so this would store NULL while the lookup searches for the id it was ' +\n 'given — the session would not be found by its own anchor and every call would create another. ' +\n \"Use 'none' for a conversation that is deliberately not addressable by anchor.\"\n )\n }\n assertSubjectAnchorId(type, id, 'anchorColumns')\n return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }\n}\n\n/**\n * 앵커 조회 조건. 보드 앵커는 **앵커 컬럼이 빈 옛 행도 잡아야** 하므로 boardId 로 찾는다\n * (dual-write 덕분에 신규 행도 같은 조건에 걸린다). 그 밖 앵커는 앵커 컬럼으로 찾는다.\n *\n * 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,\n * 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.\n */\n/**\n * 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).\n *\n * 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영\n * 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이\n * 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).\n *\n * `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.\n */\nexport function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {\n const type = assertAnchorType(anchorType, 'anchorWhere')\n assertSubjectAnchorId(type, anchorId, 'anchorWhere')\n const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }\n return kind ? { ...where, kind } : where\n}\n"]}
|