@things-factory/board-ai 10.0.10 → 10.0.13
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/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/board-ai-resolver.d.ts +20 -0
- package/dist-server/service/board-ai-resolver.js +50 -9
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/chat-message/fold-history.d.ts +14 -1
- package/dist-server/service/chat-message/fold-history.js +5 -1
- package/dist-server/service/chat-message/fold-history.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/server/service/board-ai-resolver.ts +57 -9
- package/server/service/chat-message/fold-history.ts +19 -2
- package/test/fold-history-window.test.ts +158 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/board-ai",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.13",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "npm run build:server && npm run build:client",
|
|
21
|
+
"test": "node --test --test-force-exit test/*.test.ts",
|
|
21
22
|
"build:server": "npm run clean:server && npx tsc --p ./tsconfig.json",
|
|
22
23
|
"copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
|
|
23
24
|
"build:client": "npm run clean:client && npm run copy:files && npx tsc --p ./client/tsconfig.json",
|
|
@@ -31,10 +32,10 @@
|
|
|
31
32
|
"@operato/i18n": "^10.0.0",
|
|
32
33
|
"@operato/styles": "^10.0.0",
|
|
33
34
|
"@things-factory/ai-client-base": "^10.0.8",
|
|
34
|
-
"@things-factory/auth-base": "^10.0.
|
|
35
|
-
"@things-factory/board-import": "^10.0.
|
|
35
|
+
"@things-factory/auth-base": "^10.0.13",
|
|
36
|
+
"@things-factory/board-import": "^10.0.13",
|
|
36
37
|
"@things-factory/env": "^10.0.8",
|
|
37
|
-
"@things-factory/shell": "^10.0.
|
|
38
|
+
"@things-factory/shell": "^10.0.13",
|
|
38
39
|
"dompurify": "^3.0.0",
|
|
39
40
|
"graphql-tag": "^2.12.6",
|
|
40
41
|
"lit": "^3.1.2",
|
|
@@ -44,5 +45,5 @@
|
|
|
44
45
|
"copyfiles": "^2.4.1",
|
|
45
46
|
"rimraf": "^5.0.0"
|
|
46
47
|
},
|
|
47
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "808b3b9e3bf15c058316ec4c73455108df7722aa"
|
|
48
49
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Resolver, Mutation, Arg, Ctx, Directive, Field, InputType, Int, ObjectType } from 'type-graphql'
|
|
8
8
|
import { GraphQLJSON } from 'graphql-scalars'
|
|
9
|
+
import { MoreThan } from 'typeorm'
|
|
9
10
|
import '@things-factory/auth-base'
|
|
10
11
|
|
|
11
12
|
import { config } from '@things-factory/env'
|
|
@@ -525,11 +526,12 @@ export class BoardAIChatResolver {
|
|
|
525
526
|
const sessionRepo = getRepository(ChatSession)
|
|
526
527
|
const session = await sessionRepo.findOneBy({ id: sessionId, domain: { id: domainId } as any })
|
|
527
528
|
if (!session) return
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
529
|
+
|
|
530
|
+
const stored = {
|
|
531
|
+
text: (session as any).lastSummary,
|
|
532
|
+
upToMessageId: (session as any).summaryUpToMessageId
|
|
533
|
+
}
|
|
534
|
+
const { rows, startsAfterSummary } = await this._historyForFold(sessionId, stored)
|
|
533
535
|
const history = rows.map(row => ({
|
|
534
536
|
id: row.id,
|
|
535
537
|
role: row.role,
|
|
@@ -539,10 +541,8 @@ export class BoardAIChatResolver {
|
|
|
539
541
|
}))
|
|
540
542
|
const { patch } = await foldHistorySummary({
|
|
541
543
|
rows: history,
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
upToMessageId: (session as any).summaryUpToMessageId
|
|
545
|
-
},
|
|
544
|
+
rowsStartAfterSummary: startsAfterSummary,
|
|
545
|
+
stored,
|
|
546
546
|
maxTurns: LLM_HISTORY_MAX_TURNS,
|
|
547
547
|
threshold: LLM_SUMMARY_THRESHOLD,
|
|
548
548
|
summarize: content =>
|
|
@@ -559,6 +559,54 @@ export class BoardAIChatResolver {
|
|
|
559
559
|
}
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
+
/**
|
|
563
|
+
* 요약에 필요한 만큼만 읽는다 — **요약이 덮은 지점 뒤부터.**
|
|
564
|
+
*
|
|
565
|
+
* ── 왜 (2026-08-20) ─────────────────────────────────────────────────────────
|
|
566
|
+
* 이 함수를 부르는 자리는 **메시지를 보낼 때마다**다. 예전에는 세션의 대화를 처음부터 끝까지 읽었고
|
|
567
|
+
* (`creator` 조인까지), 정작 쓰는 것은 두 조각뿐이었다: 마지막 `maxTurns`개와, 요약 워터마크 뒤부터의
|
|
568
|
+
* 미반영분. 대화가 2,000개면 매 메시지마다 2,000행을 읽어 수십 행을 쓴 셈이다.
|
|
569
|
+
*
|
|
570
|
+
* 워터마크 뒤부터 읽으면 그 구간이 곧 `미반영분 + 최근 유지분`이라, 읽는 행 수가 **대화 길이와
|
|
571
|
+
* 무관**해진다(정상 상태에서 대략 `threshold + maxTurns`).
|
|
572
|
+
*
|
|
573
|
+
* ── 안전 규율을 지킨다 ──────────────────────────────────────────────────────
|
|
574
|
+
* 워터마크 행이 지워졌거나(삭제·편집) 요약이 아직 없으면 **전체를 읽는다.** 그때는 요약이 어디까지
|
|
575
|
+
* 덮는지 알 수 없고, 「빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다」는 것이 이 기능의 규율이다
|
|
576
|
+
* (`pendingForSummary` 주석). 그 경우는 드물다.
|
|
577
|
+
*
|
|
578
|
+
* 순서는 `(createdAt, id)` — 읽는 순서와 자르는 기준이 같아야 한다. 같은 시각의 메시지가 있으면
|
|
579
|
+
* id 로 가른다(원래 정렬이 이미 그 규약이다).
|
|
580
|
+
*/
|
|
581
|
+
private async _historyForFold(
|
|
582
|
+
sessionId: string,
|
|
583
|
+
stored: { text?: string | null; upToMessageId?: string | null }
|
|
584
|
+
): Promise<{ rows: ChatMessage[]; startsAfterSummary: boolean }> {
|
|
585
|
+
const repo = getRepository(ChatMessage)
|
|
586
|
+
const session = { id: sessionId } as any
|
|
587
|
+
const order = { createdAt: 'ASC', id: 'ASC' } as any
|
|
588
|
+
const relations = { creator: true } as any
|
|
589
|
+
|
|
590
|
+
if (stored.text && stored.upToMessageId) {
|
|
591
|
+
const mark = await repo.findOne({ where: { session, id: stored.upToMessageId } })
|
|
592
|
+
if (mark?.createdAt) {
|
|
593
|
+
const rows = await repo.find({
|
|
594
|
+
/* 배열 = OR. 같은 시각 안에서는 id 로 이어 자른다(정렬 규약과 같은 축). */
|
|
595
|
+
where: [
|
|
596
|
+
{ session, createdAt: MoreThan(mark.createdAt) },
|
|
597
|
+
{ session, createdAt: mark.createdAt, id: MoreThan(mark.id) }
|
|
598
|
+
],
|
|
599
|
+
relations,
|
|
600
|
+
order
|
|
601
|
+
})
|
|
602
|
+
return { rows, startsAfterSummary: true }
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/* 워터마크가 없거나 그 행이 사라졌다 — 덮은 범위를 모르므로 전부 다시 본다. */
|
|
607
|
+
return { rows: await repo.find({ where: { session }, relations, order }), startsAfterSummary: false }
|
|
608
|
+
}
|
|
609
|
+
|
|
562
610
|
/**
|
|
563
611
|
* C4: 메시지에 @멘션된 사용자를 세션 참여자(member)로 자동 등록 — 초대.
|
|
564
612
|
* enriched content 의 {userId:UUID} 마커에서 추출 → 기존 참여자·자기 제외한 신규만 등록.
|
|
@@ -16,10 +16,23 @@ import {
|
|
|
16
16
|
} from './history-summary.js'
|
|
17
17
|
|
|
18
18
|
export interface FoldHistoryInput {
|
|
19
|
-
/** 시간 오름차순
|
|
19
|
+
/** 시간 오름차순 이력. 기본은 **전체**이고, `rowsStartAfterSummary` 면 요약 뒤부터의 구간이다. */
|
|
20
20
|
rows: HistoryRow[]
|
|
21
21
|
/** 세션에 저장된 요약 상태. */
|
|
22
22
|
stored?: StoredSummary
|
|
23
|
+
/**
|
|
24
|
+
* `rows` 가 **이미 요약 뒤부터** 잘려 온 구간인가.
|
|
25
|
+
*
|
|
26
|
+
* ── 왜 이 플래그가 필요한가 ─────────────────────────────────────────────────
|
|
27
|
+
* 호출부가 DB 에서 워터마크(`stored.upToMessageId`) 뒤부터만 읽으면(대화가 길어져도 상수 시간),
|
|
28
|
+
* 여기서 `pendingForSummary` 로 **또 자를 것이 없다.** 그런데 그 함수는 「id 를 못 찾으면 전부
|
|
29
|
+
* 미반영으로 본다」는 안전 규칙을 갖고 있어서, 창으로 읽은 경우에도 우연히 맞는 답을 낸다.
|
|
30
|
+
*
|
|
31
|
+
* 우연에 기대지 않는다. 그 규칙은 **행이 지워졌을 때의 안전망**이고, 여기서 필요한 것은 「이미
|
|
32
|
+
* 잘랐다」는 사실이다. 둘을 같은 분기로 두면, 나중에 그 안전망을 고치는 사람이 이 경로를 조용히
|
|
33
|
+
* 망가뜨린다(요약이 앞부분을 두 번 담거나 빠뜨린다).
|
|
34
|
+
*/
|
|
35
|
+
rowsStartAfterSummary?: boolean
|
|
23
36
|
/** 프롬프트 상한(이 밖으로 밀려난 것이 요약 대상). */
|
|
24
37
|
maxTurns: number
|
|
25
38
|
/** 새로 밀려난 것이 이 수 이상일 때만 요약을 만든다(매 턴 호출 방지). */
|
|
@@ -45,7 +58,11 @@ export async function foldHistorySummary(input: FoldHistoryInput): Promise<FoldH
|
|
|
45
58
|
const { dropped } = splitByCap(input.rows, input.maxTurns)
|
|
46
59
|
if (dropped.length === 0) return { patch: null, skipped: 'nothing-dropped' }
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
/*
|
|
62
|
+
* 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 요약이 덮은 앞부분은 애초에 실려 오지 않았다.
|
|
63
|
+
* 전체를 읽어 왔을 때만 워터마크로 자른다.
|
|
64
|
+
*/
|
|
65
|
+
const pending = input.rowsStartAfterSummary ? dropped : pendingForSummary(dropped, input.stored)
|
|
49
66
|
if (!shouldResummarize(pending, input.threshold)) return { patch: null, skipped: 'below-threshold' }
|
|
50
67
|
|
|
51
68
|
let text: string | undefined | null
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 요약 접기는 **필요한 만큼만 읽는다** — 그러면서 앞부분을 잃지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇을 겪었나 (2026-08-20) ──────────────────────────────────────────────
|
|
5
|
+
* `_foldHistorySummary` 는 **메시지를 보낼 때마다** 그 세션의 대화를 처음부터 끝까지 읽었다
|
|
6
|
+
* (`creator` 조인까지). 정작 쓰는 것은 두 조각뿐이다: 마지막 `maxTurns`개와, 요약 워터마크
|
|
7
|
+
* 뒤부터의 미반영분. 대화가 2,000개면 매 메시지마다 2,000행을 읽어 수십 행을 쓴 셈이다.
|
|
8
|
+
*
|
|
9
|
+
* 같은 종류의 결함을 트윈 쪽에서 먼저 잡았다(`timeRange` — min/max 두 수를 구하려고 저널 27만
|
|
10
|
+
* 행을 4초마다 읽었다). 사람 리뷰가 그것을 못 잡은 이유는 **코드가 맞았기 때문**이다. 여기도 같다:
|
|
11
|
+
* 답은 맞고 비용만 틀렸다. 그래서 이 시험은 답과 함께 **어디까지 읽어야 하는가**를 못 박는다.
|
|
12
|
+
*
|
|
13
|
+
* ── 지키는 것 ───────────────────────────────────────────────────────────────
|
|
14
|
+
* ① 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다.
|
|
15
|
+
* ② 전체를 읽어 왔으면 워터마크로 자른다 — 이미 요약에 담은 것을 두 번 담지 않는다.
|
|
16
|
+
* ③ 두 경로가 **같은 답**을 낸다. 읽는 범위를 바꾼 것이 요약 내용을 바꾸면 안 된다.
|
|
17
|
+
* ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
|
|
18
|
+
*/
|
|
19
|
+
import { test } from 'node:test'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
21
|
+
import { createRequire } from 'node:module'
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* 컴파일 산출물에서 불러온다 — 소스는 `./history-summary.js` 형태로 상대 임포트를 하고, 시험 로더는
|
|
25
|
+
* 그 `.js` 를 `.ts` 로 되돌려 주지 않는다(이 저장소의 다른 엔진 시험도 같은 이유로 dist 를 쓴다).
|
|
26
|
+
*/
|
|
27
|
+
const req = createRequire(import.meta.url)
|
|
28
|
+
const { foldHistorySummary } = req('../dist-server/service/chat-message/fold-history.js')
|
|
29
|
+
const { splitByCap, pendingForSummary } = req('../dist-server/service/chat-message/history-summary.js')
|
|
30
|
+
|
|
31
|
+
const MAX_TURNS = 5
|
|
32
|
+
const THRESHOLD = 3
|
|
33
|
+
|
|
34
|
+
/** 메시지 n개 — id 는 `m1`.. 로 두어 어디까지 담겼는지 눈으로 읽을 수 있게 한다. */
|
|
35
|
+
const rows = (n: number, from = 1) =>
|
|
36
|
+
Array.from({ length: n }, (_, i) => ({
|
|
37
|
+
id: `m${from + i}`,
|
|
38
|
+
role: (from + i) % 2 ? 'user' : 'assistant',
|
|
39
|
+
content: `말 ${from + i}`
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
/** 요약기를 가로채 **무엇이 요약 대상이 됐는지** 본다. 이것이 이 시험의 핵심 관측점이다. */
|
|
43
|
+
function capturing() {
|
|
44
|
+
const seen: string[] = []
|
|
45
|
+
return {
|
|
46
|
+
seen,
|
|
47
|
+
summarize: async (content: string) => {
|
|
48
|
+
seen.push(content)
|
|
49
|
+
return '요약'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다', async () => {
|
|
55
|
+
/* m1..m9 가 창(요약 뒤부터)이다. 마지막 5개는 원문 유지, 앞 4개가 새로 밀려났다. */
|
|
56
|
+
const cap = capturing()
|
|
57
|
+
const r = await foldHistorySummary({
|
|
58
|
+
rows: rows(9),
|
|
59
|
+
rowsStartAfterSummary: true,
|
|
60
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0-요약에-담긴-것' },
|
|
61
|
+
maxTurns: MAX_TURNS,
|
|
62
|
+
threshold: THRESHOLD,
|
|
63
|
+
summarize: cap.summarize
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
assert.ok(r.patch, '4개가 밀려났고 임계치 3 이상이므로 접는다')
|
|
67
|
+
assert.equal(r.patch!.summaryUpToMessageId, 'm4', '밀려난 마지막까지 담았다고 기록한다')
|
|
68
|
+
assert.match(cap.seen[0], /말 1/, '창의 첫 메시지가 요약에 들어간다')
|
|
69
|
+
assert.match(cap.seen[0], /말 4/)
|
|
70
|
+
assert.doesNotMatch(cap.seen[0], /말 5/, '유지분은 원문으로 남으므로 요약에 넣지 않는다')
|
|
71
|
+
assert.match(cap.seen[0], /앞선 요약/, '앞선 요약에 이어 붙인다 — 앞부분을 잃지 않는다')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('전체를 읽어 왔으면 워터마크로 자른다 — 이미 담은 것을 두 번 담지 않는다', async () => {
|
|
75
|
+
/* m1..m14 전체. m5 까지 요약에 담겼다 → 미반영분은 m6..m9(마지막 5개는 유지분). */
|
|
76
|
+
const cap = capturing()
|
|
77
|
+
const r = await foldHistorySummary({
|
|
78
|
+
rows: rows(14),
|
|
79
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
80
|
+
maxTurns: MAX_TURNS,
|
|
81
|
+
threshold: THRESHOLD,
|
|
82
|
+
summarize: cap.summarize
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
assert.ok(r.patch)
|
|
86
|
+
assert.equal(r.patch!.summaryUpToMessageId, 'm9')
|
|
87
|
+
assert.doesNotMatch(cap.seen[0], /말 5(?!\d)/, 'm5 는 이미 요약에 담겼다')
|
|
88
|
+
assert.match(cap.seen[0], /말 6/)
|
|
89
|
+
assert.match(cap.seen[0], /말 9/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('읽는 범위를 바꿔도 요약 대상은 같다 — 성능을 바꾼 것이 내용을 바꾸지 않는다', async () => {
|
|
93
|
+
/*
|
|
94
|
+
* 같은 상황을 두 방식으로 읽는다. 전체는 m1..m14 이고 m5 까지 요약됨. 창은 그 뒤인 m6..m14.
|
|
95
|
+
* 둘의 요약 대상이 다르면, 이 최적화가 사실을 옮긴 것이다.
|
|
96
|
+
*/
|
|
97
|
+
const whole = capturing()
|
|
98
|
+
const windowed = capturing()
|
|
99
|
+
|
|
100
|
+
const a = await foldHistorySummary({
|
|
101
|
+
rows: rows(14),
|
|
102
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
103
|
+
maxTurns: MAX_TURNS,
|
|
104
|
+
threshold: THRESHOLD,
|
|
105
|
+
summarize: whole.summarize
|
|
106
|
+
})
|
|
107
|
+
const b = await foldHistorySummary({
|
|
108
|
+
rows: rows(9, 6),
|
|
109
|
+
rowsStartAfterSummary: true,
|
|
110
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
111
|
+
maxTurns: MAX_TURNS,
|
|
112
|
+
threshold: THRESHOLD,
|
|
113
|
+
summarize: windowed.summarize
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
assert.equal(whole.seen[0], windowed.seen[0], '요약에 들어가는 글이 글자까지 같다')
|
|
117
|
+
assert.deepEqual(a.patch, b.patch, '기록하는 워터마크도 같다')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리지 않는다', () => {
|
|
121
|
+
/* 삭제·편집으로 그 id 가 이력에 없을 수 있다. 그때 덮은 범위를 알 수 없으므로 전부 다시 본다. */
|
|
122
|
+
const { dropped } = splitByCap(rows(14), MAX_TURNS)
|
|
123
|
+
const pending = pendingForSummary(dropped, { text: '앞선 요약', upToMessageId: '지워진-id' })
|
|
124
|
+
|
|
125
|
+
assert.equal(pending.length, dropped.length, '전부 미반영')
|
|
126
|
+
assert.equal(pending[0].id, 'm1')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('창이 유지분보다 짧으면 접지 않는다 — 새로 밀려난 것이 없다', async () => {
|
|
130
|
+
const cap = capturing()
|
|
131
|
+
const r = await foldHistorySummary({
|
|
132
|
+
rows: rows(MAX_TURNS),
|
|
133
|
+
rowsStartAfterSummary: true,
|
|
134
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
135
|
+
maxTurns: MAX_TURNS,
|
|
136
|
+
threshold: THRESHOLD,
|
|
137
|
+
summarize: cap.summarize
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
assert.equal(r.patch, null)
|
|
141
|
+
assert.equal(r.skipped, 'nothing-dropped')
|
|
142
|
+
assert.equal(cap.seen.length, 0, 'LLM 을 부르지 않는다')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('밀려난 것이 임계치 미만이면 접지 않는다 — 매 턴 요약하지 않는다', async () => {
|
|
146
|
+
const cap = capturing()
|
|
147
|
+
const r = await foldHistorySummary({
|
|
148
|
+
rows: rows(MAX_TURNS + THRESHOLD - 1),
|
|
149
|
+
rowsStartAfterSummary: true,
|
|
150
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
151
|
+
maxTurns: MAX_TURNS,
|
|
152
|
+
threshold: THRESHOLD,
|
|
153
|
+
summarize: cap.summarize
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
assert.equal(r.skipped, 'below-threshold')
|
|
157
|
+
assert.equal(cap.seen.length, 0)
|
|
158
|
+
})
|