kyd-shared-badge 0.2.34 → 0.3.1
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/package.json +11 -2
- package/src/SharedBadgeDisplay.tsx +5 -8
- package/src/ambient-ai.d.ts +9 -0
- package/src/chat/ChatWidget.tsx +315 -0
- package/src/chat/ChatWindowStreaming.tsx +56 -0
- package/src/chat/EvidenceBlock.tsx +39 -0
- package/src/chat/chat-overrides.css +138 -0
- package/src/chat/parseEvidence.ts +18 -0
- package/src/chat/types.ts +18 -0
- package/src/chat/useChatStreaming.ts +85 -0
- package/src/components/AppendixTables.tsx +2 -1
- package/src/components/ConnectedPlatforms.tsx +2 -1
- package/src/components/ProviderInsights.tsx +2 -1
- package/src/components/ReportHeader.tsx +4 -3
- package/src/index.ts +4 -0
- package/src/lib/auth-verify.ts +48 -0
- package/src/lib/chat-store.ts +96 -0
- package/src/lib/context.ts +147 -0
- package/src/lib/rate-limit.ts +29 -0
- package/src/lib/routes.ts +94 -0
- package/src/utils/date.ts +68 -0
- package/src/public/aigreen.png +0 -0
- package/src/public/aired.png +0 -0
- package/src/public/aiyellow.png +0 -0
- package/src/public/codegreen.png +0 -0
- package/src/public/codered.png +0 -0
- package/src/public/codeyellow.png +0 -0
- package/src/public/riskgreen.png +0 -0
- package/src/public/riskred.png +0 -0
- package/src/public/riskyellow.png +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type Role = 'user' | 'assistant';
|
|
6
|
+
export type ChatMessage = { id: string; role: Role; content: string };
|
|
7
|
+
|
|
8
|
+
export type UseChatStreamingConfig = {
|
|
9
|
+
api: string; // e.g. /api/chat
|
|
10
|
+
badgeId: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function useChatStreaming(cfg: UseChatStreamingConfig) {
|
|
14
|
+
const { api, badgeId } = cfg;
|
|
15
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
16
|
+
const [input, setInput] = useState('');
|
|
17
|
+
const [sending, setSending] = useState(false);
|
|
18
|
+
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
19
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
20
|
+
|
|
21
|
+
const sendMessage = useCallback(async (text?: string) => {
|
|
22
|
+
const content = (text ?? input).trim();
|
|
23
|
+
if (!content || sending) return;
|
|
24
|
+
setInput('');
|
|
25
|
+
setSending(true);
|
|
26
|
+
|
|
27
|
+
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content };
|
|
28
|
+
const assistantId = crypto.randomUUID();
|
|
29
|
+
setMessages(m => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
abortRef.current?.abort();
|
|
33
|
+
abortRef.current = new AbortController();
|
|
34
|
+
// Ensure a session exists
|
|
35
|
+
let sid = sessionId;
|
|
36
|
+
if (!sid) {
|
|
37
|
+
const sres = await fetch(`${api}/session`, { method: 'POST' });
|
|
38
|
+
if (!sres.ok) throw new Error('session');
|
|
39
|
+
const sj = await sres.json();
|
|
40
|
+
sid = sj.sessionId as string;
|
|
41
|
+
setSessionId(sid);
|
|
42
|
+
}
|
|
43
|
+
const res = await fetch(api, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ content, sessionId: sid, badgeId }),
|
|
47
|
+
signal: abortRef.current.signal,
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok || !res.body) throw new Error(`send: ${res.status}`);
|
|
50
|
+
|
|
51
|
+
const reader = res.body.getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let done = false;
|
|
54
|
+
while (!done) {
|
|
55
|
+
const chunk = await reader.read();
|
|
56
|
+
done = chunk.done || false;
|
|
57
|
+
if (chunk.value) {
|
|
58
|
+
const textPart = decoder.decode(chunk.value, { stream: true });
|
|
59
|
+
setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: msg.content + textPart } : msg));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: 'Streaming failed. Please try again.' } : msg));
|
|
64
|
+
} finally {
|
|
65
|
+
setSending(false);
|
|
66
|
+
}
|
|
67
|
+
}, [api, input, sending, badgeId]);
|
|
68
|
+
|
|
69
|
+
const cancel = useCallback(() => {
|
|
70
|
+
abortRef.current?.abort();
|
|
71
|
+
setSending(false);
|
|
72
|
+
// If the last assistant message is empty, set a friendly stopped message
|
|
73
|
+
setMessages(m => {
|
|
74
|
+
const last = m[m.length - 1];
|
|
75
|
+
if (last && last.role === 'assistant' && (last.content || '').trim().length === 0) {
|
|
76
|
+
return m.map((msg, idx) => idx === m.length - 1 ? { ...msg, content: 'Generation stopped.' } : msg);
|
|
77
|
+
}
|
|
78
|
+
return m;
|
|
79
|
+
});
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
return useMemo(() => ({ messages, input, setInput, sending, sendMessage, cancel }), [messages, input, sending, sendMessage, cancel]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { formatLocalDate } from '../utils/date';
|
|
4
5
|
import { FaGithub, FaGitlab, FaStackOverflow, FaLinkedin, FaGoogle, FaKaggle } from 'react-icons/fa';
|
|
5
6
|
import { SiCredly, SiFiverr } from 'react-icons/si';
|
|
6
7
|
import { DomainCSVRow } from '../types';
|
|
@@ -153,7 +154,7 @@ const AppendixTables: React.FC<AppendixTableProps> = ({ type, sources, searchedA
|
|
|
153
154
|
return () => window.removeEventListener('hashchange', flashIfRule);
|
|
154
155
|
}, []);
|
|
155
156
|
|
|
156
|
-
const formattedDate =
|
|
157
|
+
const formattedDate = formatLocalDate(searchedAt, {
|
|
157
158
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
158
159
|
// hour: 'numeric', minute: '2-digit',
|
|
159
160
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
|
+
import { formatLocalDate } from '../utils/date';
|
|
4
5
|
import { FaGithub, FaGitlab, FaStackOverflow, FaLinkedin, FaGoogle, FaKaggle } from 'react-icons/fa';
|
|
5
6
|
import { SiCredly, SiFiverr } from 'react-icons/si';
|
|
6
7
|
|
|
@@ -42,7 +43,7 @@ const ConnectedPlatforms = ({ accounts }: { accounts?: ConnectedAccount[] }) =>
|
|
|
42
43
|
</div>
|
|
43
44
|
{acct.observedAt ? (
|
|
44
45
|
<div className={'text-xs whitespace-nowrap'} style={{ color: 'var(--text-secondary)' }}>
|
|
45
|
-
<span style={{ color: 'var(--text-main)' }}>Observed At:</span> {
|
|
46
|
+
<span style={{ color: 'var(--text-main)' }}>Observed At:</span> {formatLocalDate(acct.observedAt)}
|
|
46
47
|
</div>
|
|
47
48
|
) : <div />}
|
|
48
49
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IconType } from 'react-icons';
|
|
2
|
+
import { formatLocalDate } from '../utils/date';
|
|
2
3
|
import { FaGithub, FaGitlab, FaStackOverflow, FaAtlassian, FaLinkedin, FaGoogle } from 'react-icons/fa';
|
|
3
4
|
import { SiFiverr, SiCredly, SiKaggle } from 'react-icons/si';
|
|
4
5
|
import { TopContributingRule } from '../types';
|
|
@@ -54,7 +55,7 @@ export default function ProviderInsights({ platforms, topContributingRules }: Pr
|
|
|
54
55
|
const display = providerDisplayMap[(r.provider || '').toLowerCase()] || r.provider;
|
|
55
56
|
return display === normalizedName;
|
|
56
57
|
});
|
|
57
|
-
const observedDate = platform.observedAt ?
|
|
58
|
+
const observedDate = platform.observedAt ? formatLocalDate(platform.observedAt, {
|
|
58
59
|
year: 'numeric',
|
|
59
60
|
month: 'short',
|
|
60
61
|
day: 'numeric',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import Image from 'next/image';
|
|
4
|
+
import { formatLocalDate } from '../utils/date';
|
|
4
5
|
import countriesLib from 'i18n-iso-countries';
|
|
5
6
|
import enLocale from 'i18n-iso-countries/langs/en.json';
|
|
6
7
|
|
|
@@ -43,7 +44,7 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
43
44
|
const finalBadgeImageUrl = badgeImageUrl || getBadgeImageUrl(score || 0);
|
|
44
45
|
const tint = hexToRgba(pickTint(score || 0), 0.06);
|
|
45
46
|
|
|
46
|
-
const formattedDate = updatedAt ?
|
|
47
|
+
const formattedDate = updatedAt ? formatLocalDate(updatedAt, {
|
|
47
48
|
year: 'numeric',
|
|
48
49
|
month: 'long',
|
|
49
50
|
day: 'numeric',
|
|
@@ -57,8 +58,8 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
57
58
|
<div className="flex flex-col md:flex-row items-center md:items-stretch gap-6">
|
|
58
59
|
{/* Left Half: Badge Image with robust centered overlay */}
|
|
59
60
|
<div className="w-full md:w-1/3 flex items-center justify-center self-stretch">
|
|
60
|
-
<div className="relative w-full max-w-xs select-none">
|
|
61
|
-
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={
|
|
61
|
+
<div className="relative w-full max-w-xs select-none" style={{ maxWidth: 260 }}>
|
|
62
|
+
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={260} height={260} unoptimized className='w-full h-auto pointer-events-none p-6'/>
|
|
62
63
|
{/* Centered overlay slightly lower on Y axis, responsive and readable */}
|
|
63
64
|
<div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
|
|
64
65
|
<div className="font-extrabold text-black text-3xl " >
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { getServerSession } from 'next-auth';
|
|
3
|
+
import { authOptions } from '@/lib/auth';
|
|
4
|
+
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
|
5
|
+
|
|
6
|
+
export type VerifiedUser = {
|
|
7
|
+
userId: string;
|
|
8
|
+
companyId?: string | null;
|
|
9
|
+
idToken: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Verifies Cognito ID Token from the server session or Authorization header.
|
|
13
|
+
// Returns userId (sub) and optional companyId claim if present.
|
|
14
|
+
export async function verifyCognito(req: NextRequest): Promise<VerifiedUser> {
|
|
15
|
+
// Try to get id_token from NextAuth session first
|
|
16
|
+
const session = await getServerSession(authOptions as any);
|
|
17
|
+
let idToken: string | undefined;
|
|
18
|
+
if (session && (session as any).id_token) {
|
|
19
|
+
idToken = (session as any).id_token as string;
|
|
20
|
+
}
|
|
21
|
+
if (!idToken) {
|
|
22
|
+
const authz = req.headers.get('authorization') || req.headers.get('Authorization');
|
|
23
|
+
if (authz && authz.toLowerCase().startsWith('bearer ')) {
|
|
24
|
+
idToken = authz.slice(7).trim();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!idToken) {
|
|
29
|
+
throw new Response(JSON.stringify({ error: 'Not authenticated' }), { status: 401 }) as unknown as Error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const region = process.env.AWS_REGION!;
|
|
33
|
+
const userPoolId = process.env.COGNITO_USER_POOL_ID!;
|
|
34
|
+
const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`;
|
|
35
|
+
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const { payload } = await jwtVerify(idToken, jwks, { issuer });
|
|
39
|
+
const sub = String(payload.sub || '');
|
|
40
|
+
if (!sub) throw new Error('Invalid token');
|
|
41
|
+
const companyId = (payload as any)['custom:companyId'] || (payload as any).companyId || null;
|
|
42
|
+
return { userId: sub, companyId, idToken };
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401 }) as unknown as Error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
2
|
+
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
|
3
|
+
import { ulid } from 'ulid';
|
|
4
|
+
|
|
5
|
+
type Role = 'user' | 'assistant' | 'system';
|
|
6
|
+
|
|
7
|
+
const region = process.env.AWS_REGION!;
|
|
8
|
+
const tableName = process.env.CHAT_TABLE_NAME!;
|
|
9
|
+
|
|
10
|
+
const ddb = new DynamoDBClient({ region });
|
|
11
|
+
const doc = DynamoDBDocumentClient.from(ddb);
|
|
12
|
+
|
|
13
|
+
export type SessionItem = {
|
|
14
|
+
pk: string; // SESSION#{sessionId}
|
|
15
|
+
sk: 'SESSION';
|
|
16
|
+
userId: string;
|
|
17
|
+
companyId?: string | null;
|
|
18
|
+
model?: string;
|
|
19
|
+
promptId?: string;
|
|
20
|
+
promptVersion?: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type MessageItem = {
|
|
26
|
+
pk: string; // SESSION#{sessionId}
|
|
27
|
+
sk: string; // MSG#{isoOrUlid}
|
|
28
|
+
role: Role;
|
|
29
|
+
content: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
redactions?: any;
|
|
32
|
+
toolCalls?: any;
|
|
33
|
+
evidenceJson?: any;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function createSession(params: { userId: string; companyId?: string | null; model?: string; promptId?: string; promptVersion?: string; }): Promise<string> {
|
|
37
|
+
const sessionId = ulid();
|
|
38
|
+
const now = new Date().toISOString();
|
|
39
|
+
let item: SessionItem = {
|
|
40
|
+
pk: `SESSION#${sessionId}`,
|
|
41
|
+
sk: 'SESSION',
|
|
42
|
+
userId: params.userId,
|
|
43
|
+
createdAt: now,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
}
|
|
46
|
+
if (params.companyId) {
|
|
47
|
+
item.companyId = params.companyId;
|
|
48
|
+
}
|
|
49
|
+
if (params.model) {
|
|
50
|
+
item.model = params.model;
|
|
51
|
+
}
|
|
52
|
+
if (params.promptId) {
|
|
53
|
+
item.promptId = params.promptId;
|
|
54
|
+
}
|
|
55
|
+
if (params.promptVersion) {
|
|
56
|
+
item.promptVersion = params.promptVersion;
|
|
57
|
+
}
|
|
58
|
+
await doc.send(new PutCommand({ TableName: tableName, Item: item }));
|
|
59
|
+
return sessionId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function putMessage(params: { sessionId: string; role: Role; content: string; evidenceJson?: any }): Promise<void> {
|
|
63
|
+
const now = new Date().toISOString();
|
|
64
|
+
const item: MessageItem = {
|
|
65
|
+
pk: `SESSION#${params.sessionId}`,
|
|
66
|
+
sk: `MSG#${ulid()}`,
|
|
67
|
+
role: params.role,
|
|
68
|
+
content: sanitizeContent(params.content),
|
|
69
|
+
createdAt: now,
|
|
70
|
+
evidenceJson: params.evidenceJson,
|
|
71
|
+
} as MessageItem;
|
|
72
|
+
await doc.send(new PutCommand({ TableName: tableName, Item: item }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getHistory(sessionId: string, limit: number = 20): Promise<Array<{ role: Role; content: string }>> {
|
|
76
|
+
const resp = await doc.send(new QueryCommand({
|
|
77
|
+
TableName: tableName,
|
|
78
|
+
KeyConditionExpression: 'pk = :pk and begins_with(sk, :msg) ',
|
|
79
|
+
ExpressionAttributeValues: { ':pk': `SESSION#${sessionId}`, ':msg': 'MSG#' },
|
|
80
|
+
ScanIndexForward: true,
|
|
81
|
+
Limit: limit,
|
|
82
|
+
}));
|
|
83
|
+
const items = (resp.Items || []) as MessageItem[];
|
|
84
|
+
return items.map(it => ({ role: it.role, content: it.content })).slice(-limit);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sanitizeContent(text: string): string {
|
|
88
|
+
// Basic server-side redaction: do not persist raw secrets-looking strings
|
|
89
|
+
try {
|
|
90
|
+
return String(text).replace(/(api|secret|token|password)[=:]\s*[^\s"']+/gi, '$1=[REDACTED]');
|
|
91
|
+
} catch {
|
|
92
|
+
return text;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Placeholder for aggregator: mimic Python aggregator semantics by reading from the same tables/blobs in future.
|
|
2
|
+
// For MVP, return a minimal shape merged from UserTable and known analysis keys if already present in user record.
|
|
3
|
+
|
|
4
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
5
|
+
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
6
|
+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
7
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
8
|
+
import { GraphInsightsPayload } from '../types';
|
|
9
|
+
|
|
10
|
+
const region = process.env.AWS_REGION!;
|
|
11
|
+
const usersTableName = process.env.USER_TABLE_NAME || `UserTable${(process.env.NEXT_PUBLIC_STAGE || process.env.STAGE || 'dev').replace(/^./, c => c.toUpperCase())}`;
|
|
12
|
+
const doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region }));
|
|
13
|
+
const s3 = new S3Client({ region });
|
|
14
|
+
const providerDataBucket = process.env.PROVIDER_DATA_BUCKET_NAME!;
|
|
15
|
+
const badgeTableName = process.env.BADGE_TABLE_NAME!;
|
|
16
|
+
|
|
17
|
+
export async function aggregateUserData(userId: string, enterpriseMode?: boolean, companyId?: string | null): Promise<any> {
|
|
18
|
+
// Minimal: load user record. Optionally, in enterprise mode, you could also fetch link overlay.
|
|
19
|
+
const userResp = await doc.send(new GetCommand({ TableName: usersTableName, Key: { userId } }));
|
|
20
|
+
const user = userResp.Item || {};
|
|
21
|
+
const merged = { ...user } as Record<string, any>;
|
|
22
|
+
if (enterpriseMode && companyId) {
|
|
23
|
+
// Optionally merge enterprise overlay later; keeping simple for MVP.
|
|
24
|
+
merged.companyId = companyId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// For each provider_* entry, fetch filtered_data_key JSON from S3 and attach under <provider>_analysis
|
|
28
|
+
const providerKeys = Object.keys(merged).filter(k => k.startsWith('provider_'));
|
|
29
|
+
const fetches = providerKeys.map(async (key) => {
|
|
30
|
+
try {
|
|
31
|
+
const providerData = merged[key];
|
|
32
|
+
if (!providerData || typeof providerData !== 'object') return;
|
|
33
|
+
const filteredKey = providerData['filtered_data_key'];
|
|
34
|
+
if (!filteredKey || typeof filteredKey !== 'string') return;
|
|
35
|
+
const obj = await getJsonFromS3(providerDataBucket, filteredKey);
|
|
36
|
+
const providerName = key.replace(/^provider_/, '');
|
|
37
|
+
const analysisKey = `${providerName}_analysis`;
|
|
38
|
+
merged[analysisKey] = obj;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error('Failed to load provider filtered data', e);
|
|
41
|
+
// Non-fatal; annotate error
|
|
42
|
+
try {
|
|
43
|
+
const providerName = key.replace(/^provider_/, '');
|
|
44
|
+
merged[`${providerName}_analysis`] = { error: 'Failed to load provider filtered data' };
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
await Promise.all(fetches);
|
|
49
|
+
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cleanDeveloperProfile(data: any): any {
|
|
54
|
+
if (Array.isArray(data)) return data.map(cleanDeveloperProfile);
|
|
55
|
+
if (data === null || typeof data !== 'object') return data;
|
|
56
|
+
const cleaned: any = {};
|
|
57
|
+
const keysToRemove = new Set([
|
|
58
|
+
'accessToken','refreshToken','access_token','refresh_token','id_token',
|
|
59
|
+
'stripeCustomerId','password','secret',
|
|
60
|
+
'gravatar_id','node_id','id','site_admin',
|
|
61
|
+
'expires_in','expires_at','token_type','scope','avatarUrls',
|
|
62
|
+
'iconUrl','extern_uid','saml_provider_id','profile_image',
|
|
63
|
+
'account_id','user_id','prompt','expiresAt',
|
|
64
|
+
'source_files_for_analysis','prompt_location','prompt_location_s3_key',
|
|
65
|
+
's3_archive_key','repos','profilePic','linkedinUrl','url','profilePictureUrl',
|
|
66
|
+
'userId','key_data_points','projects_limit','web_url','linkedinUrl',
|
|
67
|
+
'requestHistory','domains_checked','connectedAccounts', 'analysisCache',
|
|
68
|
+
'filtered_data_key', 'raw_data_key'
|
|
69
|
+
]);
|
|
70
|
+
const urlKeysToKeep = new Set(['html_url','web_url','blog']);
|
|
71
|
+
for (const [key, value] of Object.entries(data)) {
|
|
72
|
+
if (keysToRemove.has(key)) continue;
|
|
73
|
+
if (key.includes('avatar') || key.includes('picture')) continue;
|
|
74
|
+
if (key.endsWith('_url') && !urlKeysToKeep.has(key)) continue;
|
|
75
|
+
const v = cleanDeveloperProfile(value);
|
|
76
|
+
if (key === 'screening_sources' && v && typeof v === 'object') {
|
|
77
|
+
delete v['ofac_lists'];
|
|
78
|
+
delete v['additional_watchlists'];
|
|
79
|
+
delete v['risk_profile_domains'];
|
|
80
|
+
}
|
|
81
|
+
cleaned[key] = v;
|
|
82
|
+
}
|
|
83
|
+
return cleaned;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildAllContextPrompt(cleanedData: any, reportGraphData: GraphInsightsPayload | undefined, options?: { concise?: boolean, debugName?: string }): string {
|
|
87
|
+
const instructions = [
|
|
88
|
+
'You are KYD, a trust and risk analyst.',
|
|
89
|
+
'Give customer-facing answers. ',
|
|
90
|
+
'Prefer concise, direct answers.',
|
|
91
|
+
'Use ONLY the JSON provided to answer the user.',
|
|
92
|
+
'Never invent or fetch external data.',
|
|
93
|
+
'If data is missing, say so briefly.',
|
|
94
|
+
'Return natural language for the main reply.',
|
|
95
|
+
'When referring to the input JSON, use the term "Report Information" or "Report"',
|
|
96
|
+
'You are speaking to a non-technical user, ensure you do not use variable-esk language like "based on linkedin_provider", instead use "based the LinkedIn profile"',
|
|
97
|
+
// 'Optionally append a fenced JSON evidence block at the end if relevant.',
|
|
98
|
+
// 'If you output evidence, follow the exact evidence schema described.',
|
|
99
|
+
];
|
|
100
|
+
const jsonOnlyNote = 'Do not include the input JSON in your reply.';
|
|
101
|
+
// const evidenceSchema = '{ "type": "evidence", "claims": [ { "claim": str, "items": [ { "provider": str, "title": str, "url": str, "date": str } ] } ] }';
|
|
102
|
+
const out = [
|
|
103
|
+
instructions.join(' '),
|
|
104
|
+
jsonOnlyNote,
|
|
105
|
+
'-----BEGIN REPORT GRAPH DATA-----',
|
|
106
|
+
JSON.stringify(reportGraphData),
|
|
107
|
+
'-----END REPORT GRAPH DATA-----',
|
|
108
|
+
'-----BEGIN USER INPUT DATA-----',
|
|
109
|
+
JSON.stringify(cleanedData),
|
|
110
|
+
'-----END USER INPUT DATA-----',
|
|
111
|
+
].join('\n');
|
|
112
|
+
// Fire-and-forget debug write if enabled via env
|
|
113
|
+
// if (process.env.NODE_ENV === 'development') {
|
|
114
|
+
// void maybeWriteDebugContext(cleanedData, out, options?.debugName).catch(() => {});
|
|
115
|
+
// }
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function getJsonFromS3(bucket: string, key: string): Promise<any> {
|
|
120
|
+
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
121
|
+
const text = await (res as any).Body?.transformToString('utf-8');
|
|
122
|
+
return JSON.parse(text || '{}');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async function maybeWriteDebugContext(cleanedData: any, promptText: string, nameHint?: string): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
const dir = './debug';
|
|
129
|
+
await mkdir(dir, { recursive: true });
|
|
130
|
+
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_\-.]/g, '_').slice(0, 64) || 'context';
|
|
131
|
+
const base = sanitize(nameHint || 'context');
|
|
132
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
133
|
+
const jsonPath = `${dir}/kyd-${base}-${ts}.json`;
|
|
134
|
+
const txtPath = `${dir}/kyd-${base}-${ts}.txt`;
|
|
135
|
+
const jsonPretty = JSON.stringify(cleanedData, null, 2);
|
|
136
|
+
await writeFile(jsonPath, jsonPretty, 'utf8');
|
|
137
|
+
await writeFile(txtPath, promptText, 'utf8');
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error('Context debug write failed', e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
export async function getReportGraphData(badgeId: string): Promise<GraphInsightsPayload | undefined> {
|
|
145
|
+
const badge = await doc.send(new GetCommand({ TableName: badgeTableName, Key: { badgeId } }));
|
|
146
|
+
return badge.Item?.assessmentResult?.graph_insights as GraphInsightsPayload | undefined;
|
|
147
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
2
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
+
|
|
4
|
+
const region = process.env.AWS_REGION!;
|
|
5
|
+
const tableName = process.env.RATE_LIMIT_TABLE_NAME!;
|
|
6
|
+
|
|
7
|
+
const ddb = new DynamoDBClient({ region });
|
|
8
|
+
const doc = DynamoDBDocumentClient.from(ddb);
|
|
9
|
+
|
|
10
|
+
export async function checkAndConsumeToken(userId: string, capacityPerMinute: number = 10): Promise<boolean> {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const key = { userId } as any;
|
|
13
|
+
const resp = await doc.send(new GetCommand({ TableName: tableName, Key: key }));
|
|
14
|
+
const item = (resp.Item as any) || { userId, tokens: capacityPerMinute, refillAt: now };
|
|
15
|
+
let tokens: number = typeof item.tokens === 'number' ? item.tokens : capacityPerMinute;
|
|
16
|
+
let refillAt: number = typeof item.refillAt === 'number' ? item.refillAt : now;
|
|
17
|
+
if (now >= refillAt + 60_000) {
|
|
18
|
+
tokens = capacityPerMinute;
|
|
19
|
+
refillAt = now;
|
|
20
|
+
}
|
|
21
|
+
if (tokens <= 0) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
tokens -= 1;
|
|
25
|
+
await doc.send(new PutCommand({ TableName: tableName, Item: { userId, tokens, refillAt } }));
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Dummy streaming chat endpoint for local testing
|
|
2
|
+
// POST /api/chat { content: string, sessionId?: string }
|
|
3
|
+
|
|
4
|
+
import { NextRequest } from 'next/server';
|
|
5
|
+
import { streamText } from 'ai';
|
|
6
|
+
import { bedrock } from '@ai-sdk/amazon-bedrock';
|
|
7
|
+
|
|
8
|
+
import { verifyCognito } from './auth-verify';
|
|
9
|
+
import { getHistory, putMessage } from './chat-store';
|
|
10
|
+
import { aggregateUserData, cleanDeveloperProfile, buildAllContextPrompt, getReportGraphData } from './context';
|
|
11
|
+
import { checkAndConsumeToken } from './rate-limit';
|
|
12
|
+
|
|
13
|
+
import { createSession } from './chat-store';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export const runtime = 'nodejs';
|
|
17
|
+
|
|
18
|
+
export async function chatStreamRoute(req: NextRequest) {
|
|
19
|
+
try {
|
|
20
|
+
const user = await verifyCognito(req);
|
|
21
|
+
const { sessionId, content, badgeId } = await req.json();
|
|
22
|
+
if (!content || !sessionId) {
|
|
23
|
+
return Response.json({ error: 'Missing sessionId or content' }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const allowed = await checkAndConsumeToken(user.userId, 20);
|
|
27
|
+
if (!allowed) {
|
|
28
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await putMessage({ sessionId, role: 'user', content });
|
|
32
|
+
|
|
33
|
+
const aggregated = await aggregateUserData(user.userId, !!user.companyId, user.companyId);
|
|
34
|
+
const cleaned = cleanDeveloperProfile(aggregated);
|
|
35
|
+
const graphData = await getReportGraphData(badgeId);
|
|
36
|
+
const system = buildAllContextPrompt(cleaned, graphData, { concise: true });
|
|
37
|
+
const history = await getHistory(sessionId, 20);
|
|
38
|
+
|
|
39
|
+
const result = await streamText({
|
|
40
|
+
model: bedrock('us.anthropic.claude-3-5-haiku-20241022-v1:0', { region: process.env.AWS_REGION }),
|
|
41
|
+
system,
|
|
42
|
+
messages: [...history, { role: 'user' as const, content }],
|
|
43
|
+
maxTokens: 1024,
|
|
44
|
+
temperature: 0.2,
|
|
45
|
+
onFinish: async ({ text }: { text: string }) => {
|
|
46
|
+
// Attempt to capture evidence block if present (UI also parses, but cache server-side)
|
|
47
|
+
const evidence = tryParseEvidenceServer(text);
|
|
48
|
+
await putMessage({ sessionId, role: 'assistant', content: text, evidenceJson: evidence || undefined });
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return result.toTextStreamResponse();
|
|
53
|
+
} catch (e: any) {
|
|
54
|
+
if (e instanceof Response) return e;
|
|
55
|
+
console.error('chat error', e);
|
|
56
|
+
return new Response('An error occurred. Please try again.', { status: 500 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tryParseEvidenceServer(text: string): any | null {
|
|
61
|
+
try {
|
|
62
|
+
const start = text.indexOf('```json');
|
|
63
|
+
const end = text.indexOf('```', start + 7);
|
|
64
|
+
const raw = start >= 0 && end > start ? text.slice(start + 7, end).trim() : text.trim();
|
|
65
|
+
const obj = JSON.parse(raw);
|
|
66
|
+
if (obj && obj.type === 'evidence' && Array.isArray(obj.claims)) return obj;
|
|
67
|
+
} catch {}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
export async function createSessionRoute(req: NextRequest) {
|
|
76
|
+
try {
|
|
77
|
+
const user = await verifyCognito(req);
|
|
78
|
+
const body = await req.json().catch(() => ({}));
|
|
79
|
+
const sessionId = await createSession({
|
|
80
|
+
userId: user.userId,
|
|
81
|
+
companyId: user.companyId,
|
|
82
|
+
model: body?.model,
|
|
83
|
+
promptId: body?.promptId,
|
|
84
|
+
promptVersion: body?.promptVersion,
|
|
85
|
+
});
|
|
86
|
+
return Response.json({ sessionId });
|
|
87
|
+
} catch (e: any) {
|
|
88
|
+
console.error(e);
|
|
89
|
+
if (e instanceof Response) return e;
|
|
90
|
+
return Response.json({ error: 'Failed to create session' }, { status: 500 });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type DateLike = string | number | Date | null | undefined;
|
|
2
|
+
|
|
3
|
+
const hasTimezoneDesignator = (value: string): boolean => {
|
|
4
|
+
return /[zZ]$/.test(value) || /[+-]\d{2}:?\d{2}$/.test(value);
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const truncateExcessFraction = (value: string): string => {
|
|
8
|
+
// Keep at most 3 fractional digits for milliseconds
|
|
9
|
+
return value.replace(/(\.\d{3})\d+/, '$1');
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const parseUtcDate = (input: DateLike): Date => {
|
|
13
|
+
if (input == null) return new Date(Number.NaN);
|
|
14
|
+
if (input instanceof Date) return new Date(input.getTime());
|
|
15
|
+
if (typeof input === 'number') {
|
|
16
|
+
const millis = input < 1e12 ? input * 1000 : input;
|
|
17
|
+
return new Date(millis);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const raw = String(input).trim();
|
|
21
|
+
if (!raw) return new Date(Number.NaN);
|
|
22
|
+
|
|
23
|
+
// Numeric strings (epoch seconds or millis)
|
|
24
|
+
if (/^\d{10}$/.test(raw)) return new Date(Number(raw) * 1000);
|
|
25
|
+
if (/^\d{13}$/.test(raw)) return new Date(Number(raw));
|
|
26
|
+
|
|
27
|
+
// Already has timezone; trust the browser parser
|
|
28
|
+
if (hasTimezoneDesignator(raw)) return new Date(raw);
|
|
29
|
+
|
|
30
|
+
// Normalize common forms: "YYYY-MM-DD HH:MM:SS[.fff...]" -> ISO-like
|
|
31
|
+
let norm = raw.replace(' ', 'T');
|
|
32
|
+
norm = truncateExcessFraction(norm);
|
|
33
|
+
|
|
34
|
+
// Date-only string -> treat as UTC midnight
|
|
35
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(norm)) {
|
|
36
|
+
return new Date(`${norm}T00:00:00Z`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If ISO-like without timezone, explicitly mark as UTC
|
|
40
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(norm) && !hasTimezoneDesignator(norm)) {
|
|
41
|
+
return new Date(`${norm}Z`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fallback to native parsing
|
|
45
|
+
return new Date(norm);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const formatLocalDate = (value: DateLike, options?: Intl.DateTimeFormatOptions): string => {
|
|
49
|
+
const d = value instanceof Date ? value : parseUtcDate(value);
|
|
50
|
+
if (Number.isNaN(d.getTime())) return 'N/A';
|
|
51
|
+
return d.toLocaleDateString(undefined, options);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const formatLocalDateTime = (value: DateLike, options?: Intl.DateTimeFormatOptions): string => {
|
|
55
|
+
const d = value instanceof Date ? value : parseUtcDate(value);
|
|
56
|
+
if (Number.isNaN(d.getTime())) return 'N/A';
|
|
57
|
+
const base: Intl.DateTimeFormatOptions = {
|
|
58
|
+
year: 'numeric',
|
|
59
|
+
month: 'long',
|
|
60
|
+
day: 'numeric',
|
|
61
|
+
hour: 'numeric',
|
|
62
|
+
minute: '2-digit',
|
|
63
|
+
timeZoneName: 'short',
|
|
64
|
+
};
|
|
65
|
+
return d.toLocaleString(undefined, { ...base, ...(options || {}) });
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
|
package/src/public/aigreen.png
DELETED
|
Binary file
|
package/src/public/aired.png
DELETED
|
Binary file
|
package/src/public/aiyellow.png
DELETED
|
Binary file
|
package/src/public/codegreen.png
DELETED
|
Binary file
|
package/src/public/codered.png
DELETED
|
Binary file
|
|
Binary file
|